A from-the-ground-up type checker design. Goal: architecturally state-of-the-art, performance beyond tsgo — order-of-magnitude on type-level code, constant-factor on ordinary code. Not 100% parity with tsc; the goal is to preserve the type model, not runtime and emit.
One sentence the rest follows from: sacrifice runtime and emit, keep types; give each layer the computational model its problem wants.
Two key consequences:
- We are not a compiler, we are a checker. Emit, JS semantics, and runtime constructs go away.
- Type-level computation and statement-level checking are two different problems and get two different machines over one shared type representation.
┌─────────────────────────────────────────────────────────────┐
│ Frontend: oxc parser → AST (arena, bumpalo) │
├─────────────────────────────────────────────────────────────┤
│ Binder: scope graph, symbols (multi-slot), decl merging │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ Statement checker │ │ Relation │ │ Type-level │ │
│ │ (structural interp.) │◄►│ engine │ │ evaluator │ │
│ │ flow analysis, │ │ subtyping, │ │ conditional│ │
│ │ narrowing, contextual │ │ assignability│ │ /mapped/ │ │
│ │ typing, inference │ │ + caches │ │ infer │ │
│ └──────────────────────┘ └──────────────┘ └────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Type store: arena + hash-consing, TypeId(u32), SoA, stable │
│ structural hash │
└─────────────────────────────────────────────────────────────┘
Two pieces are easy to underestimate and are called out explicitly below:
- The relation engine (§6) is almost certainly the single largest CPU consumer in the
whole checker — larger than type-level evaluation. Most time in real repos
(React/Next/Nest/Prisma) goes into repeated
isAssignable(A, B), not into conditional arithmetic. - The statement checker ↔ type-level evaluator boundary (§9) is unmapped territory — no existing rewrite built both layers at once. (It only sharpens into a VM boundary if the deferred bytecode refactor of §7 is ever undertaken — ADR-0001.)
This is the entry toll for choosing Rust. A mutable cyclic graph of symbols and types is
exactly what drove the author of stc out of Rust into Go and back, and what led
Hejlsberg to pick Go for tsgo. In Rust you must design it as indices into arenas, not
Rc<RefCell<…>>. Punishment and reward are the same step: the very thing that is painful
in Rust is the biggest perf lever.
- Every type is a
TypeId(u32)— an index into a central arena. NoRc, noRefCell, no ownership cycles. - Hash-consing: structurally identical types share one node. Type construction goes
through an interner that deduplicates. Consequence: structural equality is
id_a == id_b, i.e. an integer compare instead of a recursive walk. - SoA (struct-of-arrays) layout for hot type attributes (flags, kind tag), so that comparison and filtering are cache-friendly and bit-packable.
- de Bruijn indices for type parameters and
infer, so alpha-equivalent generics (<T>(x:T)=>Tvs<U>(x:U)=>U) hash-cons to the same node. Scoped by ADR-0002: indices apply toinferbinders within conditional nodes; declaration type params stay named unique ids (context-free open types keep the relation cache sound); alpha-equivalence of generic declarations is a measured, deferred optimization.
TypeId(u32) is an arena index — it is only valid within a single process run. The moment
you want disk-cached bytecode (§7.1), incrementality (Phase 5), or any serialization,
the in-memory index is useless because the next run assigns different numbers. So every
type needs two identities:
TypeId(u32)— fast, run-local, used everywhere in-process (cache keys, VM operands).- Stable structural hash — content-addressed (e.g. blake3 over the canonical structure), computed at intern time, used for cross-run identity: disk cache validation, incremental invalidation, bytecode serialization.
This is an architectural requirement, not a detail: computing the stable hash at intern
time changes the interner's design. (Same pattern as rustc's DefId vs stable hash.)
Canonicalize before interning, or sharing falls apart:
- Unions/intersections: sort members by
TypeId, dedup, flatten nested (A | (B | C)→A | B | C). - Normalize trivial cases (
X | never→X,X & unknown→X). - Literals carry a precomputed 64-bit hash (à la TypeRunner) so literal-type comparison is an integer compare and they can key hash maps.
Cost caveat (benchmark early): canonicalizing huge unions (sort + dedup over N members, and TS routinely has unions of hundreds of members) is real work. It is a trade, not a loss — paid once at construction, repaid on every later comparison (now an integer compare), and it raises relation-cache hit rate (§6). The one case where it doesn't repay is single-use giant unions. Mitigation: canonicalize lazily on first comparison, or only above a size threshold. This is tuning, not structure — but measure it early.
A shared growing interner is global mutable state — the enemy of parallelization. Options:
a sharded interner (DashMap-like) or per-thread arenas with a deterministic merge at
phase boundaries. This decision couples interning (§3) and parallelism (§8) into one knot;
solve it in the design, don't bolt it on.
- Model name resolution and types as a scope graph (Visser/Delft line). It gives a unified resolution model and a basis for later incrementality and per-unit parallel checking.
.d.tsconsumption is mandatory core, not optional: without it you can't loadlib.d.tsor@types. Namespaces are therefore kept carefully on the type side (lib.d.tsuses them).
This is the part where a clean scope graph meets TypeScript's mutant side. The canonical nasty case:
namespace A {}
interface A {}
class A {}These collapse into one name A carrying three different meanings. Hiding this under
"sacrifice or vaguely degrade exotic combinations" is a dodge: merging is a type-side concern, and
namespace+interface merges occur in lib.d.ts itself (Symbol, globalThis), so it
can't all be sacrificed.
The fix is structural, not a pile of special cases: a symbol is not a single binding
but a set of slots over separate declaration spaces — value space, type space,
namespace space. One name can occupy several. This is exactly how tsc models it
(SymbolFlags bitmask), and a scope graph carries it cleanly if designed with this
multiplicity from the start. So: not an argument against scope graphs — an argument that
the scope graph's node must be multi-slot by design.
The target model distinguishes one unified lexical DeclId per source occurrence, a dedicated
value-storage identity, and a stable ordered TypeGroupId; none may stand in for another. Namespace
identity is separate again: reopenings share one public scope but retain private block scopes.
WU1a records the identity/group metadata dormantly without changing Symbol.ty; WU3 atomically
switches the production type slot and every consumer, with no adapter that selects a winning
fragment. Complete checker-private drafts publish once; a published type or class/static surface is
never mutated to attach a later fragment. See
ADR-0009.
Which merges to keep correct vs defer:
- Keep:
interface+interface,namespace(type container)+interface,function+namespace,class+namespace(statics), and the approvedclass+interface(+namespace) composition. In the last case the class owns theClassId/ClassInstanceidentity and attached interface instance members join its draft before atomic class publication. - Defer: exact three-way
enum+namespace+functionlegality and recovery to enum type-side work. Namespace placement diagnostics and a non-permissive function+namespace surface remain required meanwhile.
A standalone namespace is deliberately a type container only. Exported namespace values augment an existing modeled function or class/static draft; they do not create a standalone namespace runtime/value object.
Legal declare global blocks reuse the same identities through one project-wide compilation
scope. Each block has a lexical overlay whose parent is its originating module; canonical global
symbols are installed in that overlay before module-local fallthrough, so global fragments can
capture module-local helper types without merging those helpers globally. Invalid TK2669/
TK2670 blocks bind under quarantined overlay-owned identities. After binding is complete, every
user module links through the legal compilation-global scope and then the prelude. Only interfaces,
type aliases, and non-instantiated namespaces publish in this cut; variables, functions, complete
class type/value pairs, and value-bearing namespace groups remain backlog 82 and keep the explicit
incomplete channel.
Standalone interface groups freeze one immutable source-order recovery surface plus typed pending
obligations, publish each dependency SCC atomically behind a final-state capability, and only then
run heritage/conflict/relation obligations through SemanticQueryCoordinator. Outcomes fill
preallocated lexical owners but never mutate the surface. Class-owned merged groups extend the
immutable ClassInstance key to ClassId plus every argument in their effective group recovery
frame, including fragment-local mismatch positions. Function+namespace groups likewise reserve
their callable rows and exported value members first, then intern and publish one immutable callable
ObjectType; body completion cannot replace it with a bare function row.
Here we go close to checker.ts's structure (the stc philosophy), not abstract
interpretation (the ezno philosophy). Reason: narrowing and flow analysis are
flow-sensitive and need a native interpreter model; they cram awkwardly into a stack VM.
Closeness to checker.ts also means new TS features are ported, not reverse-engineered.
Responsibilities:
- Control-flow narrowing:
typeof,in, truthiness, equality, discriminated unions, assertion functions. This is the reason people love TS DX — keep it correct, never sacrifice it. - Contextual typing and the inference engine (see §5.1).
- Overload resolution.
- Driving the relation engine (§6) for every assignability question and the type-level evaluator (§7) for every type-level computation.
Easy to conflate with the relation engine, but it's a different machine. isAssignable is
a decision procedure (returns a verdict); inference is generative — it produces types
from constraints (infer extraction, contextual typing of arguments from an expected
type, type-argument inference for generic calls). It needs its own candidate-collection +
constraint-solving path, and it is roughly as large as the relation engine. Calling it out
so it doesn't get silently folded into either neighbor.
The implementation keeps type construction and semantic queries separated because the interner
requires &mut Interner while the relation engine borrows the store immutably:
- Prelude unit.
src/prelude.tsis parsed, bound, reserved, and checked first in the same type universe as the user program. The user pass keeps lifetime-free resolved type placeholders and the prelude'sDeclId → TypeIdvalue entries; string intrinsic aliases are then seeded to well-known marker types. Ordinary slot-aware parent lookup keeps user value/type declarations able to shadow their corresponding prelude slots independently. - Reservation and class publication. The checker reserves class, alias, and interface
identities, persistent binders, raw syntax slots, and lexical event tickets compilation-wide
before lowering a class surface.
ClassSurfaceLowererhas only narrow construction capabilities: it can resolve syntax and intern complete immutable nodes, but cannot evaluate, project, relate, infer calls, or select overloads. Classes form a declaration graph whose SCC condensation publishes dependency-first; a non-heritage recursive SCC appears atomically, while heritage/initializer/surface failures publish a typed poison cause instead of a partial surface. Every class application—including a non-generic empty application—is an immutableClassInstance { class, args }with a complete real argument vector. Alias and interface templates retain their ordinary reserve/fill rules; conditional/mapped/intrinsic templates stay lazy until a post-publication query demands them. - Flow graph. A pre-pass builds flow nodes for module and function bodies before checking, so loop back edges are complete when identifier reads ask for narrowed types.
- Statement walk and event replay. The checker lowers annotations, infers expressions, checks
bodies, and records obligations. Class callable rows retain their complete binders,
constraints/defaults, receiver, parameter/return types, overload position, and parameter-property
ownership from the single surface-lowering pass; body checking reuses them rather than lowering
public signatures again. All diagnostics and incomplete records have one checker-wide
EventStoreowner allocated in lexical order. Final replay sorts by(original_module_ordinal, source_start, event_ordinal, record_ordinal); SCC, completion, query, and cache order are unobservable. Speculative overload candidates write only to localCandidateEffects, and selection commits exactly one winning/final-failure set. There is no record deduplication, span deletion, truncation, or post-hoc suppression. - Semantic query phase.
SemanticQueryCoordinatoris the sole production boundary for class-reachable demand, evaluation, inference, and relation. Each operation uses fresh query-local projection/evaluation overlays and pending writes;Relaterruns read-only behind the coordinator, after applying already-planned overlay mappings and before any durable promotion. Publication/poison preflight runs before identity for both assignability and overload- compatibility entrypoints. A deferred node absent from the overlay keeps its conservative identical-only relation rule; it is not demand-evaluated merely to reach identity. Demand returnsReady | Exhausted; relation returnsYes | No(ReasonChain) | Exhausted. Poison, a 128-distinct- application projection budget, evaluation cycles, and evaluation budget failures remain typed exhaustion. Only a completed untainted query promotes evaluator, projection, and relation-cache writes together. Raw productionRelaterconstruction or assignability calls are not available.
Named type declarations are intentionally id-based. Interfaces reserve object ids; class
applications use immutable ClassInstance ids and demand one-layer structural projections only
after their declaration SCC publishes. A projection substitutes the application arguments into the
open instance template but leaves nested class applications unprojected until demanded. Transparent
aliases memoize their resolved target; object-literal aliases seed a reserved object only for the
non-generic recursive shape. ClassInstance is distinct from the lazy alias-oriented
Instantiation node. Type parameters are named unique ids, while infer binders use the scoped de
Bruijn representation described in ADR-0002.
Generic binders are persistent fields of every generic FunctionType: free functions and class,
interface/object method, call, and construct signatures share ordered descriptors carrying a unique
parameter id plus optional constraint/default. Outer substitution preserves those inner descriptors
while rewriting their free outer references; call instantiation consumes the selected signature's
descriptors through the existing inference/constraint path. Generic-to-generic relation aligns the
descriptors locally and bypasses the durable cache below that binder context, so only the completed
outer relation remains cacheable. This is the B41 representation recorded in ADR-0005.
It does not load library declarations; generic/deferred indexed access (T[K]) and optional methods
remain separately owned tails. Explicit this parameters and contextual ThisType<T> are part of
the persistent signature model.
Classes bind both spaces: the type slot names immutable instance applications, and the value slot is
the static side / constructor lookup key. Complete instance, constructor, static, parameter, and
callable metadata freezes atomically at SCC publication. Instance and static members are composed
base-first with own declarations overriding by name, but only from completely published bases;
heritage poison propagates dependency-first and ordinary non-heritage references do not inherit it.
Private/protected members carry visibility plus declaring_class identity for access control and
nominal relation checks; readonly and accessor metadata gate assignments but do not affect
assignability. Constructor signatures, abstract-member state, override-kind state, and constructor
accessibility live beside the published class surface when they are not part of the copyable new
path.
Historically the largest CPU consumer in a TS checker, bigger than evaluating conditional
types. In real repos, time disappears into repeated isRelatedTo(A, B, relation), not
into type-level arithmetic. It deserves first-class architectural treatment, not a
footnote.
With stable TypeIds, the cache key is three u32s — extremely cheap:
(TypeIdA, TypeIdB, RelationKind) → Result
RelationKind: identity, subtype, assignable, comparable, strict-subtype. They are different relations with different rules and must not share a cache.- This cache may be the single largest perf element of the whole checker; good hash-consing makes it brutally effective because structurally equal types collapse to one key.
- The durable relation cache lives in pass-local
SemanticQueryStateand is reachable only throughSemanticQueryCoordinator. The coordinator givesRelateran immutable store plus one query's normalization overlay and a pending-cache transaction. Production callers cannot construct a rawRelateror bypass coordinator-owned projection/evaluation; those direct entrypoints are test-only.
Narrowing creates swarms of short-lived types (every narrowing = a new type). A naively global relation cache gets polluted by these ephemerals and grows unbounded. So the cache needs a lifetime strategy: a per-checking-session / per-flow scope for the volatile part, with only durable relations (between named, interned, non-narrowed types) promoted to the long-lived cache. This is why it's an architectural concern, not "just turn on a HashMap."
Mutually recursive types (interface A { x: B }, interface B { x: A }) make
isRelatedTo inherently cyclic. You need an assume-true-until-disproven fixpoint: when
you re-enter a relation already on the stack, assume it holds and continue, resolving the
fixpoint at the end. Without this, recursive types loop forever. This is the sharpest edge
of the engine and must be in the core design.
Every operand applies any query-local evaluation/projection overlay mapping before identity,
durable cache lookup, or active-cycle handling. Absence from the overlay is meaningful: the deferred
node remains unchanged under its existing identical-only rule and is not eagerly evaluated before
identity. The cache and cycle key uses the resulting normalized TypeId pair plus relation kind,
never a class declaration-only key. Publication/poison preflight precedes same-id success at both
assignability and overload-compatibility entrypoints. A Yes that consumed an in-flight ancestor
assumption is provisional and cannot enter the durable cache. Likewise, poison,
planning/evaluation exhaustion, or any exhausted frontier taints the transaction: it promotes no
evaluator, projection, or relation-cache writes. Exhausted remains distinct from No, even when
the final diagnostic boundary conservatively over-reports a mismatch.
The real error-reporting pain lives in the relation engine, not the VM. "X is not
assignable to Y because property Z…" requires isRelatedTo, on failure, to return a
chain of reasons (which property, at which depth, why), not just false. So the engine
can't be a pure bool function — it must run in a "reporting mode" that builds a cause
tree on the failing path. Design this in from the start; retrofitting it onto a boolean
engine is a rewrite. (This is also the answer to the "VM error reporting is bad" worry —
most user-facing errors are relation failures, not VM faults.)
tsc has bivariant method parameters (deliberate unsoundness for the DOM). You may choose
consistently contravariant parameters — a deviation from tsc toward greater soundness,
defensible under "not 100% parity," saving a whole layer of heuristics. Variance
measurement for generics is itself cached ((GenericId, ParamIndex) → Variance).
Type-level TypeScript is a purely functional language (no loops, only recursion). It is
evaluated by a tree-walked evaluator (built with the conditional/mapped/template/utility
milestones, backlog 09–12). Its performance comes from four algorithmic properties folded
into the tree-walker — memoization, accumulator reuse, an explicit work-stack, and arithmetic
intrinsics (§7.2) — which carry the order-of-magnitude wins and are required, not optional.
A bytecode VM (IR → bytecode → stack VM, §7.1) is a potential later refactor, not a planned pillar — undertaken only if profiling on real type-level-heavy code shows the interpreter dispatch loop itself (not the algorithm, not relation/instantiation) is the bottleneck. The rationale and evidence for that demotion are ADR-0001; the rest of §7 is the design reference if that refactor is ever justified.
Scope note: even at its best the VM is a bonus, not the main win. On ordinary application code the big levers are arena layout, hash-consing, and the relation cache (§6) — see §10. The order-of- magnitude wheelhouse is a narrow slice — type-level programs (parsers, tuple arithmetic, deep recursive transforms) — and on that slice the algorithmic wins (§7.2), not bytecode dispatch, are what move the needle; bytecode adds a constant-factor on top.
type-level AST → IR → bytecode (disk-cacheable) → stack VM
The first two stages are the expensive ones and run once per file; bytecode is cached
(cold/warm split). Caveat: a warm cache is only valid if transitive type
dependencies are unchanged — tracking that graph is as hard as Salsa, it just looks
easier. Cross-run validity relies on the stable structural hash (§3.2), not on TypeId.
Iteration = recursion, so a naive recursive evaluator never leaves toy examples. The four required
minima below carry the order-of-magnitude wins — and three of the four are pure tree-walker work,
orthogonal to bytecode (ADR-0001). Build them into the evaluator as items 09–12 land:
- Memoization for non-tail (tree) recursion:
(subroutine, args) → result, keyed on hash- consed argumentTypeIds. Tree recursion (Flatten<L> & Flatten<R>) can't be tail-called; memoize + depth limit + cycle detection. The single biggest lever — no VM required. - Tuple / accumulator reuse (tail-rest): grow
[...Acc, X]in place instead of copying each iteration. Turns O(n²) accumulator building into O(n). Often a bigger win than frame elimination because allocations dominate — no VM required. - Explicit heap work-stack (trampoline), not host (Rust) recursion on
Call. Otherwise you overflow the native stack before the logical type-level limit — no VM required. - Tail-position analysis → a recursive self-call rewrites into a loop with frame reuse. This is the one item that benefits from an IR: a clean data-flow pass over IR can be more correct than tsc (an enclosing conditional whose result is further processed pushes the call out of tail position — exactly the bug tsc is finicky about). Achievable over the tree with an explicit work-stack; cleanest if/when the bytecode refactor (§7.1) materialises.
Two private evaluator walkers use heap task/value stacks for every structural child they traverse, including function type-parameter constraints and defaults. They stay separate rather than becoming a generic visitor because their policies are distinct:
InferRewriteowns lexical fresh binders, a per-run completed memo, and SCC-suffix identity taint.MappedRewriteis per assembled mapped-property value; its local memo andin_progressset return the originalTypeIdon re-entry, allowing a completed ancestor to memoize a partial clone. It has neither SCC taint nor evaluator budget semantics. Inference constraints now demand normalized types through the semantic query coordinator; there is no separate constraint-evaluator entrypoint.
These are bounded hardenings of named structural walks, not a claim that all evaluator
or repository recursion has disappeared. The remaining local keyof-intersection and
template-union helper recursion is safe under its current contracts because the
interner flattens nested intersections and unions before those helpers traverse them.
Direct arena tests exercise 10k+ deep acyclic metadata and mapped-value paths without
depending on parser nesting or the driver's enlarged worker stack.
Arithmetic, which type-level TS "hacks" via tuple lengths and template strings (extremely
expensive), is computed natively (Add, Sub, Lte…) — the tyvm lesson: the cheapest recursion
is the one you replace with a constant-time op. These are intrinsic type functions the evaluator
intercepts; they need no bytecode, only pattern recognition. As VM opcodes they're marginally
faster, but the order-of-magnitude is in not recursing at all, which the tree-walker captures.
Type-level code is shared over npm, so divergence here hurts more than in statement checking. Stay as close to tsc's limits and fragilities as possible (~50 ordinary recursion, ~1000 tail), even if your evaluator could do more — paradoxically the layer where you are technically strongest is the one where deviating pays least. Code that passes tsc should pass you.
The win is real (the tsgo lesson: much of its 10× is shared-memory parallelism, not just native code), but the shape is dictated by a hard constraint the original "rayon over parse+bind" sketch missed: the oxc AST is thread-pinned.
oxc_ast::Program is neither Send nor Sync: its arena Vecs are
deliberately !Send (two threads must never allocate into one arena), and its
nodes hold Cells (so it is !Sync). A parsed AST can therefore be neither
moved to another thread nor shared by reference — it is pinned to the
thread that parsed it.
Consequence: you cannot parse on a worker and then type-check it on a serial
thread against a shared interner (the AST can't travel to the checker), nor share
&AST across a barrier. The only thing a worker can export is owned, Send
data it produced by consuming its AST in place. So the unit of parallelism is the
whole per-file pipeline (parse → bind → check), not parse+bind alone — each
file owns its Allocator and runs end-to-end on one thread, returning owned
diagnostics. (The binder helps: bind_module is interner-free and Binder is
fully owned, so the parse+bind sub-phase touches no shared state whatsoever.)
Per-file isolation is free only while files share no types. They eventually will. Stage the shared substrate so each step keeps as much parallelism as possible:
- Stage 0 — per-file own interner (the baseline, in place today). Each file is a
self-contained universe = intrinsics + its own declarations. Sound and lossless
exactly while there is no cross-file resolution (no
lib.d.ts; the M29 project checker deliberately leaves this API untouched). Maximal parallelism, zero sharing — the correct floor, not a stopgap. Implemented asdriver::check_files(rayon over acheck_source-per-file). - Stage 0.5 — correctness-first serial project checker (M29). Local relative
.tsmodules, named imports/exports, and simple export lists are checked in a single serialInterner, so cross-fileTypeIdidentity is ordinary run-local identity. This proves module semantics before the parallel type-universe problem is solved. It is implemented asdriver::check_projectand is the CLI path fortypokat check <files...>. The planned 1.0 expansion preserves this semantic/type-universe boundary while delegating physical Bundler resolution tooxc_resolver; project enumeration, graph construction, import/export semantics,.d.tschecking, diagnostics, and determinism stay in typokat (ADR-0007). - Stage 1 — shared read-only prelude.
lib.d.ts+ intrinsics form a large, immutable, universally-needed base; re-seeding them into N per-file interners is absurd. Freeze a baseStoreonce and share it&-immutably across workers — immutable shared data isSync-friendly; the §3.4 enemy is only the growing interner. Each file layers a private delta arena over the frozen base. Parallelism survives intact. This is the first real cross-unit type-sharing, and it arrives withlib.d.ts(mandatory core, §4). - Stage 2 — cross-file mutable exports.
export interface Fooin A consumed by B: A must emit its public type surface (export name → type), and B must give those types identity in its world. A run-localTypeId(§3.2) is meaningless across interners, so this needs the stable structural hash (§3.2): serialize an export by content-hash, re-intern in the consumer. The alternative is one shared growing interner — the full §3.4 knot (sharded, or per-thread arenas with a deterministic merge at phase boundaries). This is the genuinely hard step; the M29 serial project checker exists specifically so this can remain a separate Stage 2 decision.
Parse + bind is always per-file-parallel and interner-free (Binder is owned; it
never touches types). Only the type universe / check phase climbs the Stage 0→1→2
ladder. The reserved stable_hash column in the type store (§3.2) exists precisely
for Stage 2; computing it is shared work with incrementality (Phase 5).
The cut runs between type semantics (keep, cheap) and runtime/emit semantics (sacrifice, expensive). Enum and namespace lie on both sides of that line at once.
| Sacrifice hard | Deferred type-checking coverage | Keep correct (core) |
|---|---|---|
| JS emit (all of it) | typed JS / checkJs |
structural subtyping |
| enum runtime (reverse map, IIFE, const-enum inlining) | exact enum+function+namespace type-checking legality/recovery (→ 42) |
generics + constraints + const params |
namespace runtime/emit + import = |
polymorphic this |
control-flow narrowing |
| JSDoc types in JS | variance exactly (→ more soundness) | conditional / mapped / infer |
| legacy compiler flags | — | export = semantic module export surfaces (→ 15) |
| inference engine | ||
.d.ts parsing + consumption |
Common-mistake correction: enum on the type side (= union of its members, for
narrowing in switch) and namespace on the type side (= named type container, N.T)
belong to the core, not to the sacrifice column. Only their runtime/value side is
sacrificed. A standalone namespace therefore remains a type-only container; value members are
modeled only when augmenting an existing function/class value. export = runtime emit is covered by
the general emit sacrifice, but type-checking its module export surface belongs to backlog 15;
import = may remain sacrificed. Exact enum+function+namespace type-side legality and recovery are
deferred to backlog 42, not accepted as a degraded implementation. Namespaces on the type side you
don't even get to sacrifice — lib.d.ts and @types require them.
Existing rewrites' benchmarks (TypeRunner ~400×) cannot be extrapolated to a fight with tsgo: they measure a toy (missing subtyping/narrowing/lib.d.ts), a warm cache (skipping work), and old tsc (the JS tax tsgo already paid). The more of the type system you implement, the more your profile converges to tsgo's, because the expensive work — the relation engine — is the same work in Go or Rust.
Realistic estimate vs tsgo:
| Code class | Expected speedup over tsgo | Source of the win |
|---|---|---|
| Type-level heavy (conditional/mapped DSL, arithmetic) | order of magnitude | memoization + accumulator reuse + arith intrinsics (§7.2) — a different algorithmic class, in the tree-walker; a bytecode VM adds only a constant factor on top (ADR-0001) |
| Ordinary app code (subtyping + narrowing dominate) | ~1.5–3× | layout: SoA, pointer-free arenas, no GC pauses, relation cache |
| Whole large repo | weighted average, pulled down by the type-level share | — |
Caveat on the first row: the order-of-magnitude applies to a narrow slice — type-level
programs (parsers, tuple arithmetic, deep recursive transforms). Instantiation-heavy code that
merely looks type-level (Zod-class validators: .extend/.omit chains) is dominated by
instantiation count + structural relation checks, which the relation engine (§6) and lean
instantiation address — not the evaluator. See ADR-0001.
That is a defensible SOTA architecture — measurably faster than tsgo, dramatically so
on type-level code. But "faster tsc" is SOTA in engineering; SOTA in type systems
(soundness, effect tracking — the ezno direction) is a different, more distant goal. This
design targets the former.
- The wall all five attempts hit: subtyping/variance/narrowing correctness. It's undocumented (the spec is checker.ts). No architecture sidesteps it — only grinding does. It's 10× the work of infra and is the real reason existing rewrites are unfinished, not speed.
- The VM ↔ interpreter boundary is unmapped — now off the critical path (ADR-0001 defers the
VM): when to hand a computation to the VM, how to return the result into the structural world,
sharing
TypeIdacross the boundary. No attempt has both layers —tyvmhas only the top, the others only the bottom. This risk only re-arms if the bytecode refactor is ever undertaken. - Relation cache lifetime (§6.2) is subtle: get it wrong and you either leak memory on narrowed ephemerals or lose the hit rate that makes the engine fast.
- Bytecode cache invalidation (§7.1) is not free incrementality; tracking transitive type dependencies is as hard as Salsa.
- Phase 0 — Type store + binder. Arena, hash-consing, run-local
TypeId, multi-slot scope graph, and enough.d.ts-shaped binding machinery to keep declaration spaces honest. The full stable structural hash and fulllib.d.tsare staged later; the type store is already shaped for them, but they are not prerequisites for the single-file checker. - Phase 1 — Structural interpreter + relation engine, narrow scope. Subtyping + generics + full narrowing + the relation cache and cycle handling (§6). Inference engine (§5.1). Type-level eval still in the interpreter for now (slow but correct). Goal: a usable checker on a real repo as early as possible. Completability is decided here.
- Phase 2 — Scope hardening. Variance, declaration merging (multi-slot), contextual typing, overload-bearing signatures, reporting mode (§6.4), and a minimal ambient/prelude loading slice when it buys real-world feedback before the full standard library is viable. Catching up on model coverage is the §11.1 wall.
- Phase 3 — Type-level evaluator (tree-walked). Once the core stands, build conditional/ mapped/template/utility evaluation with the four algorithmic wins folded in (§7.2: memoization, accumulator reuse, explicit work-stack, arith intrinsics). This is where the order-of-magnitude type-level speedup arrives — in the tree-walker. A bytecode VM (§7.1) is a deferred, profiling-gated refactor, not part of this phase (ADR-0001).
- Phase 4 — Real-project scale. Full
lib.d.tsas a shared read-only prelude (parallelism Stage 1), then modules/imports as an explicit staged rollout: first the shipped correctness-first whole-repo slice; then the 1.0 Bundler profile with physical resolution delegated tooxc_resolverand module semantics retained locally; then the cross-file type-identity strategy (stable structural hash or a shared growing interner) needed for parallel Stage 2. NodeNext and alternate host profiles are outside the required 1.0 ladder (ADR-0007). - Phase 5 — Incrementality (IDE). Salsa-style layer over the binder with durability (lib/deps = HIGH, workspace = LOW). This is where the stable structural hash becomes mandatory if it has not already landed for cross-file exports. A per-file bytecode cache is a complement if the VM refactor ever happens.
Plan rule: the relation engine and narrowing come before the type-level evaluator, and the
evaluator's speed lives in its algorithms, not in a VM. The bytecode VM is the sexiest piece but
the smallest share of real-world cost and the least-mapped risk (§11.2); it stays a
profiling-gated option. If time runs out, it is the first thing cut. Full lib.d.ts, modules,
parallel cross-file identity, and incrementality are staged separately so real-project feedback can
arrive before every scale feature is solved at once.