TypeScript Against the Runtime: What a Static Type Cannot See
A TypeScript type is a claim about values at compile time. Shapes, elements kinds, and inline caches are facts about objects at run time. They overlap less than the syntax suggests.
TypeScript is erased before any of the earlier chapters apply. Its structural type system deliberately says nothing about representation, so two values of one type can have different Shapes, an `enum` is an object with a reverse mapping, a discriminated union is a megamorphic site, and `as` changes nothing. This chapter maps the two type systems onto each other: where they agree (control-flow narrowing mirrors what the JIT's feedback sees), where they diverge, and how to write types that happen to keep the runtime fast. It also treats erasure itself as a specification question, now that ECMAScript has a Stage 1 proposal for type annotations as comments.
In this chapter
Erasure: the runtime never sees a type
TypeScript compiles to JavaScript by deleting annotations, interfaces, type aliases, and generics, and by rewriting the few constructs that have runtime meaning (enum, namespace, parameter properties, decorators' metadata). After that step, every chapter of this text applies unchanged and nothing from the type system remains: there is no instanceof Interface, no runtime check at a function boundary, and no representation hint to the engine. This is a deliberate design decision (TypeScript's design goals list "do not add runtime overhead" and "align with ECMAScript"), and it has two consequences that programmers coming from Java or C# find surprising. First, a type is a proof obligation on the programmer, not a guarantee from the machine: JSON.parse(text) as User is a promise you make, and the runtime will happily give you a User whose id is a string. Second, two values of the same static type may have nothing in common at run time: structural typing accepts any object with the right properties, however it was built and in whatever order, so Point may denote a dozen Shapes (chapter 3).
// What tsc emits for typed source. The types are gone; only the runtime constructs remain.
// interface User { id: number; name: string }
// const u = JSON.parse(text) as User;
const u = JSON.parse('{"id": "42", "name": "x"}'); // `as User` erased: no check happened
console.log("erased cast let a string through:", typeof u.id);
// enum Direction { Up = 1, Down } compiles to an IIFE building an object with a reverse mapping:
var Direction;
(function (Direction) {
Direction[Direction["Up"] = 1] = "Up";
Direction[Direction["Down"] = 2] = "Down";
})(Direction || (Direction = {}));
console.log("enum is an object:", JSON.stringify(Direction), "| reverse mapping:", Direction[2]);
console.log("const enum would have inlined 1 with no object at all");
// A class with parameter properties: the only place TypeScript *adds* statements.
// class P { constructor(public x: number) {} } ->
class P { constructor(x) { this.x = x; } }
console.log("parameter property became an assignment:", Object.keys(new P(1)));
// Generics leave no trace: identity<T> is identity.
const identity = (v) => v; // function identity<T>(v: T): T
console.log("generic function at runtime:", identity.length, "parameter, no type parameter");Structural types are many Shapes
TypeScript's assignability is structural: a value is a Point if it has a numeric x and y, whether it also has a z, was created by a literal, a class, Object.assign, or a spread, and regardless of property order. V8's Shapes (chapter 3) are the opposite abstraction: a Map records an exact list of properties in insertion order with their representations, and {x, y}, {y, x}, {x, y, z}, new Point(x, y), and {...p} are five different Maps. A function typed (p: Point) => number therefore sees a polymorphic or megamorphic inline cache at p.x whenever its callers build Points in more than four ways, and the type system offers no warning because it is not modelling the thing that varies. Excess-property checking on fresh object literals is TypeScript's one nod towards shape: it rejects {x: 1, y: 2, z: 3} passed directly, but not the same value stored in a variable first. The practical rule that follows from both systems at once: make the type nominal in practice by constructing it in one place, a class or a factory that assigns every field in a fixed order, and have the type be the return type of that constructor rather than a structural description anyone may satisfy.
// type Point = { x: number; y: number }. All five builders satisfy it structurally.
const builders = [
(i) => ({ x: i, y: i }),
(i) => ({ y: i, x: i }), // different insertion order
(i) => ({ x: i, y: i, tag: "extra" }), // excess property, allowed via a variable
(i) => { const p = {}; p.x = i; p.y = i; return p; }, // built from an empty literal: a different transition path, usually a different Map
(i) => Object.assign(Object.create({ z: 0 }), { x: i, y: i }), // different prototype: different Map
(i) => { class Pt { constructor(x, y) { this.x = x; this.y = y; } } return new Pt(i, i); }, // fresh class per call: fresh Map every time
];
function norm(list) { let s = 0; for (let i = 0; i < list.length; i++) { const p = list[i]; s += p.x * p.x + p.y * p.y; } return s; } // one IC for p.x, one for p.y
function bench(label, list) {
norm(list); norm(list);
const t0 = performance.now();
let r = 0; for (let k = 0; k < 8; k++) r += norm(list);
console.log(label.padEnd(44), (performance.now() - t0).toFixed(1).padStart(7), "ms", r ? "" : "");
}
const N = 300000;
bench("one builder (monomorphic)", Array.from({ length: N }, (_, i) => builders[0](i)));
bench("builders 0-1 (polymorphic)", Array.from({ length: N }, (_, i) => builders[i % 2](i)));
bench("builders 0-4 (megamorphic)", Array.from({ length: N }, (_, i) => builders[i % 5](i)));
bench("builder 5 only: a new class per object (every Shape unique)", Array.from({ length: Math.min(N, 30000) }, (_, i) => builders[5](i)));
console.log("Every list has the same TypeScript type. Only the Shape population differs.");Where the two systems agree: narrowing is what the JIT sees too
Control-flow narrowing (if (typeof x === "string"), if ("kind" in x), if (x instanceof Foo), discriminated unions on a literal kind field) is where TypeScript's reasoning and the engine's converge. The type checker narrows the static type inside the branch; the engine, having compiled the same check, has learned the same fact through feedback, and inside the branch its ICs are monomorphic. A discriminated union {kind: "circle", r} | {kind: "square", s} is a polymorphic site at shape.kind (two Shapes) that becomes monomorphic inside each case. The divergence appears at the switch itself: the more variants the union has, the more Shapes flow through the kind load, and past four the site is megamorphic regardless of how exhaustively the switch is typed. Class hierarchies behave the same way (each subclass constructor produces a different Map), which is why performance-sensitive code often replaces a union of object types with a single Shape carrying a kind field and optional fields set to undefined in every constructor path: uglier types, one Map.
// type Shape = { kind: "circle"; r: number } | { kind: "rect"; w: number; h: number } | { kind: "tri"; b: number; h: number } | ...
// Representation A: each variant its own object literal (natural TypeScript). Six variants -> six Shapes at `s.kind`.
const mkA = [
(i) => ({ kind: 0, r: i }), (i) => ({ kind: 1, w: i, h: 2 }), (i) => ({ kind: 2, b: i, h: 3 }),
(i) => ({ kind: 3, a: i }), (i) => ({ kind: 4, p: i, q: 1 }), (i) => ({ kind: 5, x: i, y: 1, z: 2 }),
];
// Representation B: one Shape for all variants; unused fields are undefined but present, in a fixed order.
const mkB = (i) => ({ kind: i % 6, r: i, w: i, h: 2, b: i, a: i, p: i, q: 1, x: i, y: 1, z: 2 });
function area(list) {
let s = 0;
for (let i = 0; i < list.length; i++) {
const v = list[i];
switch (v.kind) { // the discriminant load: one IC for every variant
case 0: s += 3 * v.r * v.r; break;
case 1: s += v.w * v.h; break;
case 2: s += v.b * v.h / 2; break;
case 3: s += v.a * v.a; break;
case 4: s += v.p * v.q; break;
default: s += v.x * v.y * v.z;
}
}
return s;
}
const N = 400000;
const A = Array.from({ length: N }, (_, i) => mkA[i % 6](i)), B = Array.from({ length: N }, (_, i) => mkB(i));
for (const [label, list] of [["A: six Shapes ", A], ["B: one Shape ", B]]) {
area(list); area(list);
const t0 = performance.now();
let r = 0; for (let k = 0; k < 6; k++) r += area(list);
console.log(label, (performance.now() - t0).toFixed(1).padStart(7), "ms", r ? "" : "");
}
console.log("Same TypeScript union either way; B trades memory (unused fields) for a monomorphic discriminant.");The unsound corners are exactly where runtime checks are missing
- Array covariance:
Dog[]is assignable toAnimal[], so code holding theAnimal[]alias can push aCatinto what is still aDog[]. The runtime has no idea; the array's elements kind just generalises (chapter 2). - Method parameter bivariance: a
(x: Dog) => voidmethod is assignable where(x: Animal) => voidis expected, for compatibility with how event handlers are written.strictFunctionTypesfixes this for function-typed properties but, by design, not for methods. - `any` and unchecked indexing:
obj[key]withkey: stringisanyunlessnoUncheckedIndexedAccessis on; the runtime returnsundefinedfor a missing key and the program continues with a value the type system said could not exist. - Declaration files:
.d.tsfiles are assertions about JavaScript nobody checked. A wrong@typespackage is a lie the compiler believes. - Erased boundaries: every
JSON.parse,fetch().json(),postMessagereceipt,localStorage.getItem, and FFI call returnsanyor an asserted type. Runtime validation (Zod, Valibot, ArkType, JSON Schema) exists to reinsert the checks that gradual typing's calculus would have generated automatically at exactly these points.
// class Animal {}; class Dog extends Animal { bark() {} }; class Cat extends Animal {}
class Animal {} class Dog extends Animal { bark() { return "woof"; } } class Cat extends Animal {}
const dogs = [new Dog()]; // Dog[]
const animals = dogs; // Animal[]: allowed, arrays are covariant in TS
animals.push(new Cat()); // legal on Animal[]
console.log("the Dog[] now contains:", dogs[1].constructor.name, "| dogs[1].bark:", typeof dogs[1].bark);
// Erased boundary: what a schema library reinserts.
const validateUser = (v) => {
if (typeof v !== "object" || v === null) throw new TypeError("not an object");
if (typeof v.id !== "number") throw new TypeError("id must be a number");
if (typeof v.name !== "string") throw new TypeError("name must be a string");
return v; // now the static type User is *earned*
};
try { validateUser(JSON.parse('{"id": "42", "name": "x"}')); } catch (e) { console.log("runtime validation catches it:", e.message); }
console.log("and passes good data:", validateUser(JSON.parse('{"id": 42, "name": "x"}')).id);Erasure as a specification: types-as-comments and Node's type stripping
Two developments move erasure out of the TypeScript compiler. Node.js (22.6+, unflagged in 23.6) can execute .ts files directly by stripping type annotations without type-checking (--experimental-strip-types, using a SWC-based stripper that replaces each annotation with whitespace so source positions survive); constructs with runtime semantics (enum, namespace, parameter properties) are rejected unless --experimental-transform-types is also given, because stripping them would change behaviour. The exporter that generates the atlas edition of this text uses exactly that mode. TC39's Type Annotations proposal (Stage 1) would make a delimited subset of annotation syntax part of ECMAScript's grammar with no semantics: function f(x: number): string {} would parse and run in any engine, with the annotation ignored like a comment. The proposal is careful to exclude everything that needs a compiler (enum, namespace, overloads' bodies, JSX). If it advances, TypeScript becomes a type checker for a language that is already JavaScript, and the build step this chapter began with disappears for the annotation-only subset. It also fixes the grammar question that stripping raises: today a < b > (c) is a comparison in JavaScript and a generic call in TypeScript, and a specification-level answer is the only one that works in every engine.
Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Structural (record) subtyping | Luca Cardelli, A Semantics of Multiple Inheritance (1984) | TypeScript's assignability is Cardelli's record subtyping: a type with more properties is a subtype. V8's Shapes are the opposite: a Map is an exact property list, so subtypes are different Shapes. |
| Gradual typing | Jeremy Siek & Walid Taha, Gradual Typing for Functional Languages (2006) | TypeScript's `any` and its unsound corners (bivariant method parameters, array covariance) are gradual typing without the runtime casts Siek and Taha's calculus inserts; erasure means nothing is checked at the boundary. |
| Type erasure | Gilad Bracha, Martin Odersky, David Stoutamire & Philip Wadler, Making the Future Safe for the Past: Adding Genericity to the Java Programming Language (1998) | Java generics established erasure as the compatible way to add types to a deployed runtime; TypeScript erases everything, not just generics, so no type exists at run time. |
| Type annotations as syntax the engine ignores | Gil Tayar, Daniel Rosenwasser, et al. (TC39), ECMAScript proposal: Type Annotations (Stage 1) (2022) | Would make a subset of TypeScript syntax valid JavaScript with comment semantics, so the erasure step would move from a compiler into the specification's grammar; Node's `--experimental-strip-types` implements the same idea as a loader. |
Primary sources
- TypeScript Design Goals (non-goals: runtime type information, soundness)
- TypeScript Handbook: Type Compatibility (structural typing, bivariance)
- TC39 proposal: Type Annotations
- Node.js: Modules — TypeScript (type stripping)
- Cardelli, L. (1984). A Semantics of Multiple Inheritance
- Siek, J. & Taha, W. (2006). Gradual Typing for Functional Languages
- Bracha, G. et al. (1998). Making the Future Safe for the Past: Adding Genericity to Java
- V8 blog: JavaScript engine fundamentals — Shapes and Inline Caches (for the runtime side)