Skip to content

Reading an optional function-typed interface field binds this when present - #614

Merged
ASDAlexander77 merged 3 commits into
fix-class-method-interface-fn-fieldfrom
fix-optional-interface-fn-field
Oct 19, 2026
Merged

ASDAlexander77 merged 3 commits into
fix-class-method-interface-fn-fieldfrom
fix-optional-interface-fn-field

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Stacked on #612 (fix-class-method-interface-fn-field); retarget to main once #612 merges.

Reading an optional function-typed interface field

interface M { fn?: () => int }: m.fn loads as optional<fn>, and InterfaceFieldAccess bound the interface's this to that optional directly, which failed verification for every implementer, object literal included:

'ts.CreateBoundFunction' op operand #1 must be ... but got '!ts.optional<!ts.func<!ts.opaque, si32, false>>'

It broke const g = m.fn, m.fn?.(), m.fn !== undefined and return m.fn.

A present field now reads as optional<bound function> (bound to ExtractInterfaceThis), an absent one as undefined. MLIRGen only: the lowering already gives a null address for a -1 slot and the cell for an INTERFACE_METHOD_CELL_TAG slot, and an optional load of a null address is undefined. The bind sits in one bindThis lambda for both paths.

A class's optional function field stays an error

#613's thunk path also took class C { fn?: () => int }, because isAnyFunctionType strips the optional. The method cell is shared by every instance, so the field read as present whether set or not, and an unset one was called. The thunk path now skips optional class fields; they stay "field type not matching", as before #613, until the cell can carry a per-instance presence check.

Tests

  • 00interface_optional_function_typed_field.ts (AOT, JIT, rc/none corpus): object literal with and without the field, this kept, a class method filling it (the method-cell path) and a class lacking it, a mixed MaybeFn[] read at one site, the bound value returned as (() => int) | undefined, a field taking a parameter. Failed to compile before the fix.
  • interface-errors/optional_class_function_field.ts: the compile error above.
  • rc --verify-ownership clean on the new test.
  • Full Release ctest -C Release -j 16 --timeout 300: 4730/4730 (3 usual not-run).

Notes

🤖 Generated with Claude Code

ASDAlexander77 and others added 3 commits October 19, 2026 13:37
…present

`interface M { fn?: () => int }`: `m.fn` loads as optional<fn>, and
InterfaceFieldAccess bound the interface's `this` to that optional directly,
which failed verification ('ts.CreateBoundFunction' op operand #1 must be ...)
for every implementer, object literal included - `const g = m.fn`,
`m.fn?.()`, `m.fn !== undefined` and `return m.fn` all hit it.

A present field now reads as optional<bound function> (bound to
ExtractInterfaceThis), an absent one as undefined. The lowering needed no
change: a -1 slot already gives a null address and a class's method cell
(INTERFACE_METHOD_CELL_TAG) its cell, and an optional load of a null address
is undefined.

The test covers object literals with and without the field, a class whose
method fills it and a class lacking it, a mixed array at one access site,
the bound value returned as `(() => int) | undefined`, and a field taking a
parameter (AOT, JIT, rc/none corpus).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ptional one

Since class fields holding a function can fill an interface's function-typed
field (a thunk in the shared method cell), `class C { fn?: () => int }` cast to
`interface M { fn?: () => int }` took that path too: isAnyFunctionType strips
the optional. The cell is the same for every instance, so the field read as
present whether it was set or not, and an unset one was called.

The thunk path now skips an optional class field, which stays "field type not
matching" as before, until the cell can carry a per-instance presence check.
A compile-error test pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… field

Under -mm=rc a bound method does not hold its receiver yet, so
`getFn(new WithFn(13))` handed back a value pointing into a freed
instance (as #612's tests did, fixed there in f12b53f).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 force-pushed the fix-optional-interface-fn-field branch from 6ce182c to 8ade6e6 Compare October 19, 2026 12:37
@ASDAlexander77
ASDAlexander77 merged commit 74eb7ac into fix-class-method-interface-fn-field Oct 19, 2026
@ASDAlexander77
ASDAlexander77 deleted the fix-optional-interface-fn-field branch October 19, 2026 12:39
ASDAlexander77 added a commit that referenced this pull request Oct 19, 2026
* A class method can fill an interface's function-typed field

`interface HasFn { fn: () => int }` with `class C { fn(): int }` failed at the
cast (`const o: HasFn = new C(42)`): mlirGenClassVirtualTableDefinitionForInterface
looked the interface's field up among the class's fields only, reported
"field type not matching for "fn"", and the caller then saw the cascade
"not a function to call". TS accepts a method for a function-typed property.

The class has no field holding the method, so the slot can't be the usual
this-relative offset. It now holds the address of a private global cell
(`<C>.<I>..vtbl.<field>.method`) holding the method, plus
INTERFACE_METHOD_CELL_TAG (bit 0, free in every function-typed slot: field
offsets and the cell are pointer-aligned). InterfaceSymbolRefOpLowering reads
the cell when the bit is set, ahead of the optional-storage tag. The read in
InterfaceFieldAccess is unchanged (Load + CreateBoundFunction over
ExtractInterfaceThis), so the rc receiver tag work applies to it as is.

A write through such a field changes the shared cell, i.e. the method for
every instance seen through that interface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* One method cell for a field reached twice through a diamond extends

An interface inheriting the same function-typed field from two parents gives
the field two vtable slots; registering a cell per slot failed with
"redefinition of symbol". The test now also covers `implements`, a base class
that implements the interface, and the diamond.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* A class field holding a function can fill an interface's function-typed field (#613)

* A class field holding a function can fill an interface's function-typed field

`class W { fn: () => int = () => 7 }` cast to `interface HasFn { fn: () => int }`
failed with "field type not matching": the class stores the field as a hybrid
function ({this-or-null, fn}), while the interface slot is read as a function
taking the object as its `this`. Neither a null-this hybrid (no `this`
parameter) nor a bound one (its own receiver) can be read that way.

The field callback of mlirGenClassVirtualTableDefinitionForInterface now
accepts a class field of any function kind whose signature matches, and
synthesizes a private thunk `<Class>.<Iface>..vtbl.<field>.thunk(this, ...)`
that calls `this.<field>(...)`. The thunk fills the slot through the method
cell added for class methods (INTERFACE_METHOD_CELL_TAG), so the field is read
at every call and a later assignment to it is seen through the interface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cover an unannotated function-typed class field filling an interface field

`fn = () => 6` is stored as a plain function rather than a hybrid one; it
reaches the interface through the same thunk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the receiver alive where the interface tests return a bound field

Under -mm=rc a bound method does not hold its receiver yet: `return o.fn`
from a function that made `new C(42)` handed back a value pointing into a
freed instance. Linux CI's JIT rc run read the freed block (assert in
00interface_function_typed_field_class.ts); with glibc's tcache off the
class-field test crashed calling through the freed field. The caller now
owns the instance, as the rest of these tests already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reading an optional function-typed interface field binds this when present (#614)

* Reading an optional function-typed interface field binds `this` when present

`interface M { fn?: () => int }`: `m.fn` loads as optional<fn>, and
InterfaceFieldAccess bound the interface's `this` to that optional directly,
which failed verification ('ts.CreateBoundFunction' op operand #1 must be ...)
for every implementer, object literal included - `const g = m.fn`,
`m.fn?.()`, `m.fn !== undefined` and `return m.fn` all hit it.

A present field now reads as optional<bound function> (bound to
ExtractInterfaceThis), an absent one as undefined. The lowering needed no
change: a -1 slot already gives a null address and a class's method cell
(INTERFACE_METHOD_CELL_TAG) its cell, and an optional load of a null address
is undefined.

The test covers object literals with and without the field, a class whose
method fills it and a class lacking it, a mixed array at one access site,
the bound value returned as `(() => int) | undefined`, and a field taking a
parameter (AOT, JIT, rc/none corpus).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* A class's optional function field stays an error for an interface's optional one

Since class fields holding a function can fill an interface's function-typed
field (a thunk in the shared method cell), `class C { fn?: () => int }` cast to
`interface M { fn?: () => int }` took that path too: isAnyFunctionType strips
the optional. The cell is the same for every instance, so the field read as
present whether it was set or not, and an unset one was called.

The thunk path now skips an optional class field, which stays "field type not
matching" as before, until the cell can carry a per-instance presence check.
A compile-error test pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the receiver alive where the optional-field test returns a bound field

Under -mm=rc a bound method does not hold its receiver yet, so
`getFn(new WithFn(13))` handed back a value pointing into a freed
instance (as #612's tests did, fixed there in f12b53f).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant