Skip to content

Write a class's function field through an interface (h.f = g) - #711

Merged
ASDAlexander77 merged 1 commit into
mainfrom
fix-interface-class-fn-field-write
Oct 28, 2026
Merged

ASDAlexander77 merged 1 commit into
mainfrom
fix-interface-class-fn-field-write

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Problem

When a class field holding a function fills an interface's function-typed field, assigning to that field through the interface didn't compile, under every memory model:

class Item { constructor(public n: number) {} }
interface Holder { f: (v: Item) => void; }
class Walker { constructor(public f: (v: Item) => void) {} }
function show(v: Item) { print(v.n); }
function set(h: Holder) { h.f = show; }   // error: saving to constant object
function main() { const w = new Walker(show); set(w); w.f(new Item(3)); }

The interface's slot for f points at a cell holding a thunk that calls this.f(...), so a store through the interface had no field to write to.

Fix

The user decided on 2026-10-28 that class fields are writable through the interface and methods and object literals are not. This narrows the 2026-10-19 rule that every interface function field is read-only.

  • Cell layout: the cell is now {method, flag offset, field offset}.
    • The field offset is the class field's offset when that field is stored as the interface field's function made a hybrid. That is the case when both are declared f: (v: T) => void.
    • The new helper MLIRTypeHelper::getInterfaceFieldFunctionAsHybrid builds that hybrid type.
    • Every other cell has -1 there: a method's, a field of another type, an optional field, and an object literal's thunk cell.
  • MLIRGen: mlirGenSaveInterfaceFunctionField handles h.f = x.
    • It matches the bound read of the field. For an optional field that read is wrapped in ts.If.
    • It clones the field's InterfaceSymbolRef with INTERFACE_FUNCTION_FIELD_WRITE_ATTR and a ref to the hybrid type, then stores through it with the usual load/store path, which does the rc retain/release.
  • Lowering: with the attribute set, the address is this plus the cell's field offset. It is null for a method's cell (offset -1) and for an untagged slot, which is an object literal's field.
  • Run-time stop, not a compile error, for methods and object literals: the object behind the interface is only known at run time. When the address is null, an assert stops the program with: an interface's function field can't be assigned when the object fills it with a method or is an object literal; only a class field holding the function can be.
  • Optional fields still refused at compile time: a write to an optional fn?: would also have to set the instance's presence flag. The error now says an optional function-typed interface field can't be assigned through the interface; assign it on the object.

-mm=own

collectFieldFamilies doesn't see the store through the interface, because there is no ts.PropertyRef there. The family stays sound because the cell initializer takes a ts.PropertyRef of the field to compute its offset. That use is neither a load nor a store, so it opens the field's family.

On main this costs no precision, because a class cast to an interface already escapes. Under §58 (#710), a class seen through an interface can keep its field families. That PR would then need to treat the cell as a store only when a write-marked InterfaceSymbolRef for that field exists in the module; this is noted in the spec.

In the interface-facts scan, the new write ref is used by a cast, for the null test, so it counts as unknown. That is sound and only affects programs that store through an interface, which didn't compile before.

Tests

  • tests/00interface_function_field_write.ts:
    • a named function stored through the interface and read back on the instance and through the interface;
    • each instance keeps its own function;
    • a closure over a local stored through the interface.
    • It runs under gc and own, AOT and JIT, and under rc and none in the corpus.
  • interface-function-field-write/{method,literal}.ts: the run-time stop under gc, rc, none and own (JIT).
  • interface-errors/function_field_write.ts: the optional case, with the new message.

ctest -C Release: 6063/6063 passed (Windows, Release).

🤖 Generated with Claude Code

A class field holding a function fills an interface's function-typed field
through a thunk cell, so `h.f = show` had nowhere to store and failed with
"saving to constant object" under every memory model.

The cell is now {method, flag offset, field offset}. The field offset is the
class field's, when that field is stored as the interface field's function
made a hybrid (`f: (v: T) => void` in both), else -1. A store through the
interface clones the field's InterfaceSymbolRef with
INTERFACE_FUNCTION_FIELD_WRITE_ATTR and a ref to that hybrid; the lowering
gives `this` + the cell's field offset, or null for a method's cell or an
object literal's field. Which object is behind the interface is known only
at run time, so a null address stops the program with a message saying that
only a class field holding the function can be assigned (USER decision
2026-10-28: class fields are writable, methods and object literals are not).
An optional interface function field (`fn?:`) is still refused at compile
time, now with a message that says so.

-mm=own: the store has no ts.PropertyRef for collectFieldFamilies to see; the
cell initializer's PropertyRef of the field (its offset) is neither a load nor
a store, so the field's family is open. Spec section updated.

Tests: 00interface_function_field_write.ts (gc, own AOT+JIT, rc/none corpus),
interface-function-field-write/{method,literal}.ts runtime aborts under the
four models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit c460e49 into main Oct 28, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the fix-interface-class-fn-field-write branch October 28, 2026 10:14
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