Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions tslang/docs/superpowers/plans/2026-10-19-final-classes.md

Large diffs are not rendered by default.

160 changes: 160 additions & 0 deletions tslang/docs/superpowers/specs/2026-10-19-final-classes-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# `@final` classes

Date: 2026-10-19. Status: design, awaiting approval.

## Why

The default library does not build under `-mm=own`. After the `@nodrops` work (compiler branch
`own-nodrops-declarations`, DefaultLib branch `own-default-lib`) 14 errors remain in `src/lib.ts`.
Seven of them have one cause: a method call on an exported class goes through the vtable, and
another module could subclass that class and override the method. So own cannot resolve the call
(`OwnershipSignaturePass::resolveFamily` requires every family member to be private and defined in
this module), and an unresolved call:

- may destroy anything, so a borrow of a field cannot be lent to it. This affects the `String`
wrapper's `match`, `replace`, `replaceAll`, `search` and `split`: each one hands `this.value` to a
`___String` function that calls a `RegExp` method.
- returns a value that is not fresh (spec 2026-09-24 section on freshness: only a direct call of a
function defined in this module is fresh), so it cannot be moved into a union or compared and
then returned. This affects `Date[Symbol.toPrimitive]`'s `return this.toString()` (lib.ts
432/435) and, in the debug build only, `___String.match`'s `if (result == null)` after
`expr.exec(this)`.

The user's ruling: DefaultLib classes are final in practice, and nobody overrides them. A class
should be able to say so with a `@final` decorator.

## Goals

- `@final` on a class declaration: no class may extend it. This is enforced as a compile error, in
the module that defines the class and in every module that sees its declaration.
- A method or accessor call on a value whose static type is a `@final` class is a direct call to
the implementation that type has, not a vtable call. Own then resolves it with the rules it
already has for direct calls: no-drops from the body, a fresh result, result-borrows facts, and
stack promotion and borrow facts the same way.
- Mark the DefaultLib classes `@final` in `lib.ts` and in their `lib.d.ts` declarations: `RegExp`,
`MatchResults`, `MatchIndicesResults`, `String`, `Number`, `Boolean`, `BigInt`, `Date`, `Headers`
and `Response`. `ArrayBuffer` has no `lib.d.ts` declaration, so it is marked in `lib.ts` only.
`console` and `Math` are `static` classes with no instances and stay unmarked.
- The seven errors listed above go away. The `Headers` constructor's `k + ""` copies, a workaround
for the same cause, are reverted.

## Non-goals

- The other remaining errors are a separate decision ("an exported function cannot keep or borrow
a parameter": `matchAll`, `String.iterator`, `Response.constructor`, and the mislocated
`lib.ts:151` error, which comes from `matchAll`'s generated `next`). They stay.
- Own facts crossing a static link. A user program under own that calls `re.exec(s.value)` still
sees the library's methods as declarations without facts. That is a follow-up project (the same
mechanism as the borrow facts of 2026-10-18).
- `@final` on individual methods. It is class-level only.
- Adding `-mm=own` to DefaultLib's `build.bat` / `build.sh` loops. That waits until `lib.ts`
compiles under own with no errors.

## Anti-goals

- Changing a program's behaviour under gc, rc or none. A direct call reaches the same function
the vtable would have reached, because no subclass can exist. Only the dispatch changes.
- Dropping the vtable of a `@final` class. Interfaces the class implements, `instanceof`, and calls
through a non-final base type still use it.
- Breaking the cross-module paths. A `@final` class from a DLL loaded at run time (`isDynamicImport`,
no import library) must keep its vtable calls, because a direct call there would be a symbol that
cannot be linked.

## Design

### Parsing and storage

- `#define FINAL_DECORATOR "final"` in `Defines.h`, beside `CPP_VTABLE`.
- `ClassInfo` gets a `bool isFinal` (in `MLIRGenStore.h`, default false). It is set in the class
decorator loop in `MLIRGenClasses.cpp` (the `iterateDecorators(classDeclarationAST, ...)` block at
about line 361), on declarations (`declare class`, `lib.d.ts`) as well as definitions.
- `DeclarationPrinter` prints `@final` before a final class, as it prints `@cpp` (about line 436),
so `-shared` importers (`__decls`) see it.

### Enforcement

- Where a class's `extends` clause is resolved (the `heritageClauses` loops in
`MLIRGenClasses.cpp`), a base class with `isFinal` is an error at the `extends` expression:
`class 'X' cannot extend 'RegExp': it is @final`. It applies in every memory model.
- `@final` together with `abstract` is an error, since an abstract class must be extended.
`@final` with `@cpp` is also an error: a C++ binding is dispatched through the C++ vtable.

### Dispatch

A method or accessor reference for a class instance is virtual at two sites:
`ClassMethodAccess` in `MLIRGenImpl.h` (about line 8045,
`if (methodInfo.isAbstract || methodInfo.isVirtual && !isStorageType)`, creating
`ThisVirtualSymbolRefOp` at about 8067) and `ClassAccessorAccess` in `MLIRGenAccessCall.cpp`
(about line 705). Each gains one exception: when the *receiver's* static type is a `@final`
class that is not `isDynamicImport`, it emits the direct `ThisSymbolRefOp` to `funcName`.

The `classInfo` these functions see is not always the receiver's. For an inherited member,
`ClassMembersAccess` recurses through `ClassBaseClassAccess` with the base's `ClassInfo`
(`MLIRGenAccessCall.cpp` about 947/1012). So the receiver's finality has to be passed down
through `ClassMembersAccess` -> `ClassBaseClassAccess` -> `ClassMethodAccess` /
`ClassAccessorAccess`, as a flag or as the receiver's `ClassInfo`. The decision then reads:

- A method the final class defines or overrides is called directly.
- A method it inherits from a non-final base without overriding it is called directly too, as
`Base.m`. With no subclass, that is the only implementation a value of this static type can have.
- A call through a variable typed as the non-final base, or as an interface, is unchanged.

The index accessor (`ClassIndexAccess`, as in `MatchResults[i]`) already emits a direct
`ThisIndexAccessorOp`, and `super.` calls are already direct (their `this` is a storage type), so
neither changes. The builder should confirm that no other `VirtualSymbolRefOp` /
`ThisVirtualSymbolRefOp` creation applies to a class instance.

Direct calls name the method by symbol, so for user programs they also get past a known
`lib.d.ts` / `lib.ts` mismatch. `lib.d.ts`'s `RegExp` leaves out `split`, which `lib.ts` defines,
so a user program's vtable index for `[Symbol.dispose]` does not match the library's. Public
methods and accessors of exported classes are already in the import library, so the direct
calls link, compiled or JIT. Under rc, `OwnedReturnConsumptionPass` treats a direct call to a
`lib.d.ts` declaration as it treats a virtual one.

**Amended after the final review (2026-10-19).**

- **Direct calls only into this module.** A member is called directly only when the class that has it is defined in this module, not declared there (`isDeclaration`, `isImport`, `isDynamicImport`). A `@static import` links by name against the import library, which has no symbol for a member of a base the library does not export, so inheriting such a member broke the link. A user program's calls into the default library's `@final` classes therefore stay vtable calls. That costs nothing the goals need: own facts do not cross a static link anyway. The extends error still applies to declared classes.
- **No other class can pose as a `@final` one.** An unrelated class that fits a `@final` class member by member is not assignable to it (`type Y is not assignable to type F: 'F' is @final`), because the direct call would run F's body on Y's fields.

### Own

No change in the ownership passes is planned. A `ThisSymbolRef` call is what `resolveValue`
already resolves (`addDefined`, or `addImported` for an own library over a DLL import), and it
canonicalizes to the `SymbolCallInternal` that `isFresh` accepts. `noDrops` does not depend on a
function being open, so the public `RegExp.exec` called directly gets its body's facts.

The builder checks this first, on the three lib.ts shapes (`re.exec(s)` returning
`MatchResults | null`, `this.toString()` returned as `string | number`, and
`this.value.match(expr)`, whose callee destroys its own local `MatchResults` after `[...result]`).
If one still fails because of a real imprecision in a pass, fixing it properly is part of this
project, with a minimal repro, a fix and an own test, as the `@nodrops` branch did for four such
imprecisions. *(Builder's call, made by the designer: the user left scope open.)* A workaround
that only bends a pass for these shapes is not. If a remaining error turns out to be correct
(the code really is unsafe under own), it is reported, and the library change for it is decided
separately.

## Testing

- Compiler `test/tester`:
- Positive own tests (`own/`, run AOT and JIT): a `@final` class with a virtual method whose
result is returned through a union and compared with null, and a wrapper class that lends a
field to a `@final` class's method. Each comes with a `churn()` of same-size objects (the
use-after-free rule from the rc work).
- Error cases: `extends` of a `@final` class, `@final abstract`, `@final @cpp`.
- An IR check that a call on a `@final` static type emits no `ThisVirtualSymbolRef`, while one
through its interface still does.
- A `-shared` pair: an exporting module with a `@final` class and an importer that calls it and
fails to extend it.
- The full suite stays green (4682/4682 on the base branch).
- DefaultLib:
- Release builds of gc and rc pass `tests.ps1`, compiled and JIT.
- `lib.ts` under own: the seven listed errors are gone (release and `--di`), and the remaining
count is only the non-goal sites.

## Left to the builder

- The error message wording, and whether to give the user a fix-it hint.
- Whether `isFinal` also skips emitting `.instanceOf` overrides. It should not: `instanceof`
still needs them.
- Branch layout. Stack this on `own-nodrops-declarations` (whose commit is still waiting on GPG
signing), or rebase once that lands.
11 changes: 11 additions & 0 deletions tslang/include/TypeScript/Defines.h
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,14 @@
#define SHARED_LIB_BORROW_FACTS_ATTR_NAME "ts.borrow_facts"
// a module attribute, so it has to carry the dialect's prefix
#define SHARED_LIB_OWN_NO_DROPS_ATTR_NAME "ts.own_imported_no_drops"
// `@nodrops declare function strncmp(...)`: a function defined outside TypeScript, a C function,
// that destroys nothing a TypeScript caller can reach. -mm=own calls it the way it calls an
// imported function in SHARED_LIB_OWN_NO_DROPS_ATTR_NAME, so a borrow of a field can be given to
// it. A declaration has no body to check, so this is the declaration's word, and only a
// declaration can give it: never on one that may run TypeScript code (it takes a callback) or
// one re-parsed from a TypeScript library, whose functions may destroy what they are given.
#define NO_DROPS_DECORATOR "nodrops"
#define OWN_DECLARED_NO_DROPS_ATTR_NAME "ts.own_declared_no_drops"

// The process-heap allocator of a Windows module (TypeScript/ProcessHeap.h), which ProcessHeapPass
// calls instead of the C runtime's. Defined by the async runtime library a program links,
Expand All @@ -239,6 +247,9 @@
#define LINK_NAME "linkname"
// A class laid over an existing C++ object: its vtable is the C++ one (see ClassInfo::isCppVTable)
#define CPP_VTABLE "cpp"
// `@final class C`: no class may extend C, so a call on a value of type C is a direct call to the
// method C has (docs/superpowers/specs/2026-10-19-final-classes-design.md)
#define FINAL_DECORATOR "final"
// the old name of @cpp, an error now: unknown, it would leave the class an ordinary one
#define CPP_VTABLE_OLD_NAME "cppvtable"
// A @cpp method's C++ return type has a destructor or a copy constructor (see
Expand Down
5 changes: 4 additions & 1 deletion tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,9 @@ struct ClassInfo
// vtable's slots from 0 in declaration order, a base class's first: no `.instanceOf`, `.new`,
// interface or static-field slots, no RTTI, and no vtable or constructor of its own.
bool isCppVTable;
// `@final`: nothing extends it, so a method of a value of this type is called directly. The
// vtable stays: interfaces, `instanceof` and calls through a base type use it.
bool isFinal;
// a @cpp class with methods that have bodies: C++ methods implemented here, so C++ can
// call them through its objects. It has a vtable (the C++ one's layout: the slots and nothing
// else, a slot left to a declared C++ method null), may have a constructor and field
Expand All @@ -813,7 +816,7 @@ struct ClassInfo
ClassInfo()
: isDeclaration(false), hasNew(false), hasConstructor(false), constructorAccessLevel(mlir_ts::AccessLevel::Public), hasInitializers(false), hasStaticConstructor(false),
hasStaticInitializers(false), hasVirtualTable(false), isAbstract(false), isExport(false), isImport(false),
isPublic(false), isDynamicImport(false), hasRTTI(false), isCppVTable(false),
isPublic(false), isDynamicImport(false), hasRTTI(false), isCppVTable(false), isFinal(false),
processingAtEvaluation(ProcessingStages::NotSet), processing(ProcessingStages::NotSet)
{
}
Expand Down
7 changes: 7 additions & 0 deletions tslang/lib/TypeScript/DeclarationPrinter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,13 @@ namespace typescript
newline();
}

// an importer calls a @final class's methods directly and may not extend it
if (classType->isFinal)
{
os << "@" FINAL_DECORATOR;
newline();
}

// `abstract` is layout-relevant, not just semantic: mlirGenClassNew skips
// synthesizing the `.new` method for abstract classes, so dropping the
// modifier makes the reimporting module insert a `.new` vtable slot the
Expand Down
2 changes: 1 addition & 1 deletion tslang/lib/TypeScript/LowerToAffineLoops.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2937,7 +2937,7 @@ void AddTsAffineLegalOps(ConversionTarget &target)
mlir_ts::LoadOp, mlir_ts::LoadSaveOp, mlir_ts::NewOp, mlir_ts::CreateTupleOp, mlir_ts::DeconstructTupleOp, mlir_ts::CreateArrayOp,
mlir_ts::NewEmptyArrayOp, mlir_ts::NewArrayOp, mlir_ts::DeleteOp, mlir_ts::PropertyRefOp, mlir_ts::InsertPropertyOp,
mlir_ts::ExtractPropertyOp, mlir_ts::LogicalBinaryOp, mlir_ts::UndefOp, mlir_ts::VariableOp, mlir_ts::DebugVariableOp, mlir_ts::AllocaOp,
mlir_ts::InvokeOp, /*mlir_ts::ResultOp,*/ mlir_ts::VirtualSymbolRefOp, mlir_ts::ThisVirtualSymbolRefOp, mlir_ts::InterfaceSymbolRefOp,
mlir_ts::InvokeOp, /*mlir_ts::ResultOp,*/ mlir_ts::VirtualSymbolRefOp, mlir_ts::ThisVirtualSymbolRefOp, mlir_ts::ThisSymbolRefOp, mlir_ts::InterfaceSymbolRefOp,
mlir_ts::ExtractInterfaceThisOp, mlir_ts::ExtractInterfaceVTableOp, mlir_ts::ArrayPushOp, mlir_ts::ArrayPopOp,
mlir_ts::ArrayUnshiftOp, mlir_ts::ArrayShiftOp, mlir_ts::ArraySpliceOp, mlir_ts::ArrayViewOp,
mlir_ts::NewInterfaceOp, mlir_ts::VTableOffsetRefOp, mlir_ts::GetThisOp, mlir_ts::GetMethodOp, mlir_ts::DebuggerOp,
Expand Down
35 changes: 34 additions & 1 deletion tslang/lib/TypeScript/LowerToLLVM.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2049,6 +2049,11 @@ struct FuncOpLowering : public TsLlvmPattern<mlir_ts::FuncOp>
continue;
}

// read by -mm=own only
if (name == OWN_DECLARED_NO_DROPS_ATTR_NAME) {
continue;
}

// module-local: a private symbol alone still comes out external
if (name == "internal_linkage") {
newFuncOp->setAttr("llvm.linkage", LLVM::LinkageAttr::get(getContext(), LLVM::linkage::Linkage::Internal));
Expand Down Expand Up @@ -6360,6 +6365,34 @@ struct ThisVirtualSymbolRefOpLowering : public TsLlvmPattern<mlir_ts::ThisVirtua
}
};

// A method of a known class read as a value, not called (`const fn = f.m` on a @final class's
// object): the method bound to its object, as ThisVirtualSymbolRefOpLowering binds the vtable's.
// A call through it was folded into a direct call before this lowering.
struct ThisSymbolRefOpLowering : public TsLlvmPattern<mlir_ts::ThisSymbolRefOp>
{
using TsLlvmPattern<mlir_ts::ThisSymbolRefOp>::TsLlvmPattern;

LogicalResult matchAndRewrite(mlir_ts::ThisSymbolRefOp thisSymbolRefOp, Adaptor transformed,
ConversionPatternRewriter &rewriter) const final
{
auto boundFunc = dyn_cast<mlir_ts::BoundFunctionType>(thisSymbolRefOp.getType());
if (!boundFunc)
{
thisSymbolRefOp->emitError("unsupported this symbol reference type");
return failure();
}

Location loc = thisSymbolRefOp.getLoc();
auto thisOpaque = rewriter.create<mlir_ts::CastOp>(loc, mlir_ts::OpaqueType::get(rewriter.getContext()),
transformed.getThisVal());
auto method = rewriter.create<mlir_ts::SymbolRefOp>(
loc, mlir_ts::FunctionType::get(rewriter.getContext(), boundFunc.getInputs(), boundFunc.getResults()),
thisSymbolRefOp.getIdentifierAttr());
rewriter.replaceOpWithNewOp<mlir_ts::CreateBoundFunctionOp>(thisSymbolRefOp, boundFunc, thisOpaque, method);
return success();
}
};

struct InterfaceSymbolRefOpLowering : public TsLlvmPattern<mlir_ts::InterfaceSymbolRefOp>
{
using TsLlvmPattern<mlir_ts::InterfaceSymbolRefOp>::TsLlvmPattern;
Expand Down Expand Up @@ -8689,7 +8722,7 @@ void TypeScriptToLLVMLoweringPass::runOnOperation()
SharedNewOpLowering, SharedValueRefOpLowering, SharedCountOpLowering,
StringCompareOpLowering, AnyCompareOpLowering, CharToStringOpLowering, UndefOpLowering, CopyStructOpLowering, MemoryCopyOpLowering, MemoryMoveOpLowering,
LoadSaveValueLowering, ThrowUnwindOpLowering, ThrowCallOpLowering, VariableOpLowering, DebugVariableOpLowering, AllocaOpLowering, InvokeOpLowering,
InvokeHybridOpLowering, VirtualSymbolRefOpLowering, ThisVirtualSymbolRefOpLowering, InterfaceSymbolRefOpLowering,
InvokeHybridOpLowering, VirtualSymbolRefOpLowering, ThisVirtualSymbolRefOpLowering, ThisSymbolRefOpLowering, InterfaceSymbolRefOpLowering,
NewInterfaceOpLowering, VTableOffsetRefOpLowering, LoadBoundRefOpLowering, StoreBoundRefOpLowering, CreateBoundRefOpLowering,
CreateBoundFunctionOpLowering, GetThisOpLowering, GetMethodOpLowering, TypeOfOpLowering, TypeOfAnyOpLowering, DebuggerOpLowering,
UnreachableOpLowering, SymbolCallInternalOpLowering, CallInternalOpLowering, CallHybridInternalOpLowering,
Expand Down
Loading
Loading