Skip to content

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

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

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

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Stacked on #612: merge that first, then retarget this PR to main.

class W { fn: () => int = () => 7 } cast to interface HasFn { fn: () => int } failed with field type not matching for "fn", although both print as () => s32.

Cause

The class stores the field as !ts.hybrid_func<, si32>, a {function, this or null} pair: a signature type node always becomes a HybridFunctionType. The interface field is !ts.func<!ts.opaque, si32>, a function that takes the object as this. The slot can't hand out a hybrid that way: a null-this hybrid has no this parameter, and a bound one has its own receiver.

Change

  • In the field callback of mlirGenClassVirtualTableDefinitionForInterface, a class field of function kind whose signature matches (TestFunctionTypesMatchWithObjectMethods) is accepted.
  • A private thunk <Class>.<Iface>..vtbl.<field>.thunk(this, p0..pn) is synthesized that calls this.<field>(p0..pn) (generateSynthMethodToCallField, MLIRGenImpl.h, modelled on generateSynthMethodToCallNewCtor). It handles void returns too.
  • The thunk fills the slot through A class method can fill an interface's function-typed field #612's method cell. It reads the field at every call, so a later assignment to the instance's field is seen through the interface.

The limitation from #612 still applies: a write through the interface (h.fn = g) changes the shared cell.

Tests

00interface_function_typed_field_class_field.ts (gc AOT/JIT + rc/none corpus). It covers:

  • the task program, an unannotated fn = () => 6 field (stored as a plain function), a capturing closure, and a bound method assigned in the constructor
  • a field reassigned after the cast, (int, string) => string, and a void return
  • implements, inheritance, and a diamond extends
  • one call site shared with an object literal, and the returned bound value

It failed on #612's binary with the original error. Full Release ctest passed (4723/4723). --verify-ownership is clean under rc and none. A signature mismatch still errors, and the message now names the field's type.

Found, not fixed (separate bugs)

  • Casting an object literal to the interface drops a closure's context: const o = { fn: cl }; const h: HasFn = o; h.fn() prints garbage.
  • class W2 { fn = () => 0; constructor(k: int) { this.fn = () => k; } } crashes on w.fn().
  • class W3 { fn = this.m }: w.fn() fails with "not a function to call".

🤖 Generated with Claude Code

ASDAlexander77 and others added 2 commits October 19, 2026 12:57
…ed 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>
…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>
@ASDAlexander77
ASDAlexander77 merged commit 2a523c6 into fix-class-method-interface-fn-field Oct 19, 2026
@ASDAlexander77
ASDAlexander77 deleted the fix-class-fn-field-interface branch October 19, 2026 12:07
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