Skip to content

A class method can fill an interface's function-typed field - #612

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

ASDAlexander77 merged 5 commits into
mainfrom
fix-class-method-interface-fn-field

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

interface HasFn { fn: () => int } with class C { fn(): int { ... } } failed at the cast (const o: HasFn = new C(42)) with field type not matching for "fn", and callers then saw the cascade "not a function to call". TypeScript accepts a method for a function-typed property.

Change

  • mlirGenClassVirtualTableDefinitionForInterface (MLIRGenClasses.cpp): when the interface field has no class field of that name, a non-static method with a matching signature fills it (the method's this is skipped in the match).
  • The class has no storage for the method, so the slot can't be the usual this-relative offset. It holds the address of a private global cell <C>.<I>..vtbl.<field>.method (which holds the method), plus INTERFACE_METHOD_CELL_TAG (Defines.h, bit 0). Bit 0 is free in every function-typed slot: field offsets and the cell are pointer-aligned.
  • InterfaceSymbolRefOpLowering (LowerToLLVM.cpp) reads the cell when the bit is set. It tests the bit before INTERFACE_OPTIONAL_STORAGE_TAG, which a 32-bit cell address can carry; -1 (absent) is still tested first.
  • A field reached twice through a diamond extends has two slots and one cell.
  • InterfaceFieldAccess is unchanged (Load + CreateBoundFunction over ExtractInterfaceThis).

Known limitation: a write through such a field (o.fn = g) changes the shared cell, i.e. the method for every instance of the class seen through that interface.

Tests

00interface_function_typed_field_class.ts (gc AOT/JIT + rc/none corpus): structural cast, the returned bound value, parameters, an inherited method, implements, a base class that implements the interface, the diamond, and one call site shared by a class and an object literal. Full Release ctest passed on each commit.

A stacked follow-up (a class field holding a function) builds on this cell.

🤖 Generated with Claude Code

ASDAlexander77 and others added 2 commits October 19, 2026 12:22
`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>
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>
ASDAlexander77 and others added 2 commits October 19, 2026 13:07
…ed 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>
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>
@ASDAlexander77

Copy link
Copy Markdown
Owner Author

Linux CI failure (test-jit-rc-corpus-00interface-function-typed-field-class, assert) was the tests, not the fix: under -mm=rc a bound method does not hold its receiver yet, so return o.fn from a function that made new C(42) returned a value pointing into a freed instance. Reproduced in WSL with GLIBC_TUNABLES=glibc.malloc.tcache_count=0 MALLOC_PERTURB_=165 (class test printed 32 and asserted; the class-field test crashed). f12b53f has the caller own the instance in both tests; green under rc/none on Linux (tcache off) and Windows. The compiler-side fix (bound methods / interface function fields holding their receiver) is on the local, unpushed fix-rc-bound-this-more.

ASDAlexander77 added a commit that referenced this pull request Oct 19, 2026
… 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>
…esent (#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>
@ASDAlexander77
ASDAlexander77 merged commit 498d473 into main Oct 19, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the fix-class-method-interface-fn-field branch October 19, 2026 13:38
ASDAlexander77 added a commit that referenced this pull request Oct 19, 2026
Two class-field bugs with no interface involved:

- `fn = () => 0` was stored as a plain function, which has no room for a
  closure's context: assigning a capturing closure to it later warned
  "losing this reference" and the call crashed (0x80000003). A field whose
  function type is inferred is now stored as a hybrid function, as an
  annotated `fn: () => int` field and a `let` (adjustLocalVariableType) are.

- `fn = this.m` had no type in the discovery pass: the fields are laid out
  before the methods are declared, so the field became `any` there and
  `w.fn()` failed with "not a function to call" before the real pass ran.
  Such a field now holds its place and is typed again once the members are
  declared (mlirGenClassRetypeFields); the code made against the placeholder
  is thrown away with the pass. The real pass already typed it as the bound
  method.

The field type computation moved into mlirGenClassDataFieldType so both
places share it. 00class_function_typed_field_inferred.ts covers both forms,
reassignment, a closure over the bound-method field and the interface path
of #612, under gc AOT/JIT and the rc/none corpus.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ASDAlexander77 added a commit that referenced this pull request Oct 19, 2026
`interface HasFn { fn: () => int }` with `const o = { fn: cl }`, `cl` a
capturing closure: `const h: HasFn = o; h.fn()` printed garbage. The
literal stores the closure as a bound function (its `this` is the capture
tuple), the interface slot is read as a function taking the object as its
`this`. The cast cloned the object with the interface's field type, and
bound_func -> func<opaque> dropped the captures, so the call bound the
object where the captures belonged. A hybrid field (a type-literal
annotated one) crashed the same way once it captured, an interface method
(`fn(): int`) did too, and a plain function (`{ add: plusOne }`) received
the object as its first argument.

Such a field - bound, hybrid, or a plain function without a `this`
parameter (MLIRTypeHelper::isObjectFieldFunctionWithoutThis) - now matches
the interface's function-typed member as it is, and keeps its own type in
a clone and in a literal built for the interface. The object's vtable
calls it through a thunk, `<vtable>.<field>.thunk(this, ...)`, that reads
the field and calls it (generateSynthMethodToCallField, now taking the
`this` type, shared with classes): a method's slot holds the thunk, a
function-typed field's slot a tagged method cell holding it, as for a
class since #612. As there, a write through the interface changes the
shared cell.

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