Skip to content

Erasure & Emit

Emit is the fourth stage — and most of TypeScript’s syntax skips it entirely

Section titled “Emit is the fourth stage — and most of TypeScript’s syntax skips it entirely”

The index lesson’s pipeline — parse, bind, check, emit — ends with emit turning a checked AST into JavaScript (and, if asked, .d.ts). The overwhelming majority of what makes TypeScript TypeScript — every type annotation, interface, generic, as cast — contributes nothing to that output at all: it’s deleted, in full, the moment checking finishes. This lesson is about the short, specific, enumerable list of syntax that is the exception — constructs that need the compiler to generate different code, not merely delete an annotation — and about what still needs to happen even after every type is gone (.d.ts emit).

The complete erasable-vs-not list, with the real code per item

Section titled “The complete erasable-vs-not list, with the real code per item”

--erasableSyntaxOnly (5.8) exists to make this list mechanically checkable rather than a matter of memory: it rejects exactly the constructs that need a transform, and nothing else. Every diagnostic below is the real, verified TS1294 from compiling the smallest file that triggers it on 7.0.2 — one item per row, so a mixed file can be diagnosed construct by construct:

ConstructErasable?Verified result under --erasableSyntaxOnly
enum Color { Red, Green }NoTS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
const enum Color { Red, Green }NoSame TS1294 — const changes the emit strategy (see below), not whether the syntax needs one
namespace Ns { export const x = 1; } (has runtime values)NoSame TS1294
namespace Ns { export interface Foo { x: number } } (type-only)YesCompiles clean — a namespace containing only types has nothing to emit
constructor(public x: number, public y: number) {} (parameter properties)NoTwo separate TS1294s — one per modified parameter, since each one independently needs a this.x = x assignment generated
import fs = require("node:fs")NoTS1294
export = WidgetNoTS1294 (also TS1203 if the module target is ESM-only — a separate, unrelated complaint about the target, not about erasability)
Legacy decorators (experimentalDecorators)Yes, by this flag’s own ruleNo TS1294 — the syntax (@dec on a class) is identical to stage-3 decorators; erasableSyntaxOnly is a purely syntactic check and cannot tell legacy and stage-3 apart from the @ alone
Stage-3 decorators (standard, no flag needed)YesCompiles clean under --erasableSyntaxOnly
JSX (<div>hi</div>)Not part of this list at all--erasableSyntaxOnly never flags JSX either way — it isn’t TypeScript-specific syntax, so it’s outside the scope of a flag whose entire purpose is catching TypeScript’s own non-erasable constructs

Two results above are worth pausing on because they contradict a natural guess. First, a type-only namespace compiles clean — the rule isn’t “namespaces are banned,” it’s “a namespace with nothing but types has nothing to generate,” which is exactly consistent with everything else on this list. Second, legacy decorators pass this specific check even though the Classes & Advanced OOP module’s decorators lesson already establishes that legacy decorators depend on tsc’s own emit and can’t run under a type-stripping runtime — erasableSyntaxOnly and “can this actually run without a real compile step” are two different axes, and legacy decorators land on opposite sides of each: syntactically erasable-looking, but semantically dependent on a transform the flag doesn’t check for.

// @skip-verify --erasableSyntaxOnly isn't part of this course's fixed verification flags,
// and `import =`/`export =` need a CommonJS module target this file's flags don't set either --
// this is illustrative source; the real command and output are quoted below, run on this machine
enum Color { Red, Green }
const enum ConstColor { Red, Green }
namespace HasValues { export const x = 1; }
namespace TypesOnly { export interface Foo { x: number } } // erasable -- no TS1294 for this line
class Point { constructor(public x: number, public y: number) {} }
import fs = require("node:fs");
class Widget {}
export = Widget;
$ tsc --ignoreConfig --noEmit --erasableSyntaxOnly --strict --target es2025 --module commonjs --skipLibCheck --types node erasable-demo.ts
erasable-demo.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
erasable-demo.ts(2,12): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
erasable-demo.ts(3,11): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
erasable-demo.ts(5,27): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
erasable-demo.ts(5,45): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
erasable-demo.ts(6,1): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
erasable-demo.ts(8,1): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

Seven diagnostics, all TS1294, none on line 4 — one per non-erasable construct, and two for the parameter-property line (one per modified parameter, x and y independently).

.d.ts emit: isolatedDeclarations and its real TS90xx codes

Section titled “.d.ts emit: isolatedDeclarations and its real TS90xx codes”

Ordinary --declaration emit can print a type it never had to fully re-derive, because the whole-program checker already resolved everything — including, as the index lesson’s TS4094 example showed, structurally inlining a type that has no exported name to reference. --isolatedDeclarations (5.5) is a narrower, stricter opt-in: it requires every exported value to carry an explicit type annotation, so a .d.ts can be generated one file at a time, in parallel, by a tool that never runs the full checker — exactly what a bundler plugin or a fast, non-tsc build step needs.

// @skip-verify --isolatedDeclarations, like --erasableSyntaxOnly above, isn't part of this
// course's fixed verification flags -- illustrative source; real output quoted below
export function add(a: number, b: number) {
return a + b;
}
$ tsc --ignoreConfig --isolatedDeclarations --declaration --emitDeclarationOnly --strict --target es2025 --skipLibCheck add.ts
add.ts(2,10): error TS9013: Expression type can't be inferred with --isolatedDeclarations.

The return type is entirely inferable in an ordinary build, but a single-file tool has no way to look at a + b and know the answer without re-implementing a chunk of the checker, so the flag simply requires the annotation up front: export function add(a: number, b: number): number. A second, differently-shaped TS90xx fires for a different single-file blind spot:

// @skip-verify --isolatedDeclarations again -- illustrative source; real output quoted below
const pair = { x: 1, y: 2 };
export const { x, y } = pair;
$ tsc --ignoreConfig --isolatedDeclarations --declaration --emitDeclarationOnly --strict --target es2025 --skipLibCheck destructure.ts
destructure.ts(2,16): error TS9019: Binding elements can't be exported directly with --isolatedDeclarations.
destructure.ts(2,19): error TS9019: Binding elements can't be exported directly with --isolatedDeclarations.

A destructured export requires resolving pair’s shape to know what x and y even are (one TS9019 per destructured name), which is exactly the kind of cross-expression inference a single-file tool can’t do. Both diagnostics share one underlying rule: isolatedDeclarations is only satisfied when a .d.ts generator could produce the exact same declaration output looking at one export at a time, with zero type inference — which is a strictly narrower bar than plain --declaration, and precisely why real generated code sometimes fails it even when the equivalent whole-program build succeeds.

const enum: erasure that only works with whole-program knowledge

Section titled “const enum: erasure that only works with whole-program knowledge”

A const enum is TypeScript’s most instructive erasure case, because whether it actually gets erased depends entirely on how much of the program the tool doing the emitting can see — not on anything about the enum declaration itself:

// Same const enum, three different emitting contexts, three different outcomes (verified):
// 1. tsc, whole program, this enum used in the SAME file it's declared in:
const enum Color { Red, Green, Blue }
export const c = Color.Green;
// emits: export const c = 1 /* Color.Green */; -- fully inlined, the enum itself vanishes
// 2. A single-file tool (ts.transpileModule -- the same engine that powers
// this course's TSPlayground) sees only this one file, with no idea what
// the rest of the program does with `Color`:
// emits: a full runtime object for Color, IDENTICAL to what a real (non-const) enum
// would emit -- transpileModule cannot inline what it cannot see used elsewhere
// 3. An ambient const enum from ANOTHER package, imported under --isolatedModules:
export declare const enum Color { Red, Green, Blue } // in a .d.ts from a dependency
import { Color } from "some-package";
export const c = Color.Green;
// real error: TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled.

Case 1 is the ordinary, celebrated behavior: const enum promises “no runtime object, just inlined literals,” and a real tsc build with full program knowledge keeps that promise. Case 2 is a genuine, verified surprise: ts.transpileModule — a single-file transform with no cross-file knowledge, the same kind of tool tsx, most bundlers, and this course’s own in-browser playground use — cannot inline a value it has no way to resolve in isolation, so it silently falls back to emitting the exact same runtime object a plain enum would produce. Case 3 is the one TypeScript actively refuses rather than silently degrading: an ambient const enum (declare const enum, the shape a .d.ts from an external package uses) crossing an isolatedModules boundary is refused outright with TS2748, because isolatedModules promises every file can be compiled without looking at any other file, and inlining a value declared only in another package’s .d.ts — with no local implementation to fall back to the way case 2 had — is exactly the cross-file knowledge that promise rules out. preserveConstEnums sits between these: it keeps const enum’s compile-time inlining behavior for values used within the checked program, while also emitting the backing runtime object (rather than erasing it, as case 1 does by default) — useful specifically when some other, less whole-program-aware tool downstream needs the object to exist even though tsc itself no longer needs it.

This is precisely the shape of bug the ai-bug-catalog lesson’s “const enum across a package boundary” entry describes from the reviewing-code angle; the mechanism reason it happens is this lesson’s case 3 exactly.

Case 2 is provable live, right here, and along the way it demonstrates something else worth knowing about this course’s two buttons: Check types and Run use two different engines that don’t have to agree. A raw numeric reverse-mapping lookup on a const enum (Color[1], or Color[Color.Green]) is flagged by real semantic checking — TS2476: A const enum member can only be accessed using a string literal — precisely because the checker can’t guarantee a reverse-mapping object survives inlining in the general case. Run doesn’t type-check at all; it transpiles with ts.transpileModule (syntax-directed only) and executes whatever comes out. Click Check types below and you’ll see the real TS2476. Click Run anyway, and it executes fine — proof, empirically, that in this single-file context the object genuinely exists to look up:

Check types rejects this; Run executes it anyway — proving the object exists

// @expect-error TS2476
const enum Color { Red, Green, Blue }
console.log(Color.Green); // 1 -- looks inlined so far...
console.log(Color[Color.Green]); // Check types: real TS2476. Run it anyway -- it works.
// Run output:
// 1
// Green
Open in TS Playground

The emit table: what actually happens to each construct, tool by tool

Section titled “The emit table: what actually happens to each construct, tool by tool”

The Compiler & Tooling module covers this same territory from the tooling angle — which tool to reach for and why. This is the checker’s angle: for each non-erasable construct, does a given emitting path even attempt the transform, and does the result run? Every cell below is a real, verified result on this machine — not a claim from documentation.

Constructtsc (real emit)tsx (runs it)esbuild --loader=tsnode --experimental-strip-types
enum Color { Red, Green }Full runtime object, IIFE-wrappedRuns, prints correct valuesFull runtime object (near-identical shape to tsc’s)Rejected — type-stripping only, no transform
Parameter properties (constructor(public x))Emits the this.x = x assignmentRuns, this.x assignment presentEmits the field + assignment, same shapeRejected
namespace Ns { export const x = 1 }Emits an IIFE wrapping NsRuns correctlyEmits an IIFE, same shapeRejected
export = WidgetEmits module.exports = Widget (CJS)Runs correctlyEmits module.exports = Widget under --format=cjsRejected
Legacy decorators (experimentalDecorators)Emits __decorate helper + callsRuns correctlyEmits its own __decorateClass helper + calls (same idea, different helper name)Runs the syntax, but as a stage-3 decorator call — a different runtime semantics than what experimentalDecorators promised, since stripping types does not run tsc’s own decorator transform
Stage-3 decorators (standard syntax)Passes through with minimal transformRuns correctlyPasses throughRuns correctly — this is real, engine-native ECMAScript syntax as of recent Node/V8, not something that needs erasing

The node --experimental-strip-types row is the one that matters most in practice: it is a type-stripping runtime, meaning it deletes annotations and nothing else — it has no transform step at all, so every construct in the left column above that needs one is a hard rejection, not a degraded or partial result. tsx and esbuild both do carry a real transform (they aren’t just stripping), which is why they succeed on every row — the meaningful line isn’t “does this tool understand TypeScript,” it’s “does this tool merely strip, or does it also transform,” and only Node’s built-in path is strip-only among the four.

One-line history: importsNotUsedAsValues → verbatimModuleSyntax

Section titled “One-line history: importsNotUsedAsValues → verbatimModuleSyntax”

TypeScript briefly had two separate, narrower flags for the same underlying problem — whether an import gets kept or erased — before consolidating them: --importsNotUsedAsValues and --preserveValueImports were deprecated in TypeScript 5.0’s own words because understanding their combined edge cases was “hard, and there are still some edge cases with unexpected behavior,” in favor of --verbatimModuleSyntax’s single, much simpler rule quoted directly from the same release: “any imports or exports without a type modifier are left around [kept]. Anything that uses the type modifier is dropped entirely.” The Build & Emit lesson covers verbatimModuleSyntax itself in full; the reason it exists at all is this lesson’s theme one more time — a rule about what gets erased is only trustworthy if it’s simple enough to predict without running the compiler and reading the output.

flowchart TD
  syntax["A TypeScript construct"] --> ask{"Does the compiler need to GENERATE
different code, or just DELETE an annotation?"}
  ask -->|generate| nonErasable["Non-erasable: enum, namespace-with-values,
parameter properties, import =, export =
(erasableSyntaxOnly: real TS1294)"]
  ask -->|delete only| erasable["Erasable: types, interfaces, generics,
type-only namespaces, stage-3 decorators, JSX"]
  nonErasable --> tool{"What tool is emitting it?"}
  tool -->|tsc, full program| works["Transformed correctly, whole-program knowledge available"]
  tool -->|tsx, esbuild -- single-file WITH a transform| worksToo["Transformed correctly -- these tools carry the emit logic themselves"]
  tool -->|node --experimental-strip-types -- STRIP ONLY, no transform| rejected["Hard rejection -- there is no transform step to fall back on"]
Erasability is a property of syntax; emit needs are a property of the tool doing the emitting

Two independent questions run through every fact in this lesson, and conflating them is the single most common mistake: “is this syntax erasable” is a property of the construct itself, checkable by --erasableSyntaxOnly with no knowledge of any particular runtime; “will this specific tool actually produce working output for it” depends entirely on whether that tool carries a real transform (tsc, tsx, esbuild) or merely deletes annotations (node --experimental-strip-types). A construct can be syntactically non-erasable and still run everywhere that has a transform, or syntactically “erasable-looking” (stage-3 decorators) yet only run correctly on a runtime new enough to understand it natively — erasability and runnability are related, but they are not the same axis.

ChoiceBenefitCost
Writing only erasable syntax (--erasableSyntaxOnly)Guaranteed to run under Node’s type-stripping, tsx, and every other strip-only path with zero transform step neededGives up enum’s reverse mapping, parameter-property shorthand, and legacy decorators — real ergonomic losses for real ergonomic gains
const enumZero runtime cost when a whole-program build can see every use siteSilently degrades to a full runtime enum under a single-file tool (transpileModule), and is refused outright (TS2748) as an ambient import across an isolatedModules boundary — its “zero cost” promise is conditional, not universal
--isolatedDeclarations.d.ts files can be generated per-file, in parallel, by a tool that never runs the full checkerReal, verified friction (TS9013, TS9019) on code that would compile fine under plain --declaration — a strictly narrower bar
verbatimModuleSyntax over the older importsNotUsedAsValues/preserveValueImports pairOne simple, predictable rule (type modifier present or not) instead of two flags’ worth of edge casesRequires actually annotating type-only imports with type — a real, if small, migration cost for existing code
  • --erasableSyntaxOnly checks whether code will run on a given runtime. It checks a narrower, purely syntactic question: does this construct need the compiler to generate something. Legacy decorators pass this check (same syntax as stage-3) yet still can’t run under type-stripping, because they depend on tsc’s own emitted helper functions — a separate, semantic dependency erasableSyntaxOnly doesn’t detect.
  • const enum is always erased to a bare literal, with zero runtime footprint, no matter what compiles it. Only true when the tool doing the emitting has whole-program knowledge. A single-file transform (transpileModule, and by extension anything built on the same kind of isolated transform) falls back to emitting a full runtime object instead — verified directly, not a theoretical edge case.
  • isolatedDeclarations is just a stricter version of --declaration that catches the same things earlier. It’s a genuinely different bar: code that compiles cleanly under plain --declaration (because the whole-program checker can infer everything) can fail isolatedDeclarations (TS9013, TS9019) specifically because a single-file declaration generator couldn’t have inferred the same thing alone.
  • JSX must be one of the “erasable” or “non-erasable” TypeScript constructs, since it also gets transformed away. --erasableSyntaxOnly never mentions it either way — JSX isn’t TypeScript-specific syntax, so it sits entirely outside the scope of a flag whose only job is policing TypeScript’s own non-standard constructs.

Emit-related bugs in generated code tend to surface only once the code leaves the environment it was written and tested in:

  • Any generated .ts file meant to run directly under Node’s type-stripping, or in tsx/Deno/Bun with a similar strip-only fast path, that uses enum, a parameter property, or namespace with real values: this is a real, hard rejection, not a warning — check for these constructs specifically before assuming “it’s just TypeScript, it’ll run somewhere.”
  • A const enum exported from one package and imported by another under isolatedModules: this is exactly the shape that produces TS2748 for an ambient declaration, or silently degrades to a full runtime object under a single-file build tool for a non-ambient one — neither failure mode is obvious from reading the enum declaration alone.
  • Generated code claiming isolatedDeclarations-compatibility (common in monorepo/library-scaffolding output) that actually has un-annotated exports or destructured exports: verify with the real flag rather than trusting the generator’s claim, since the two real diagnostics in this lesson (TS9013, TS9019) are exactly the shapes that slip through when a generator was only checked against plain --declaration.

🐛 Spot the bug

// utils.ts (a shared internal package)
export const enum LogLevel { Debug, Info, Warn, Error }
// app.ts (a consumer, built with isolatedModules for fast per-file bundling)
import { LogLevel } from "./utils";
export function shouldLog(level: LogLevel, threshold: LogLevel) {
return level >= threshold;
}
Show the answer

Whether this compiles depends entirely on one detail this snippet doesn’t show: whether utils.ts’s declaration is consumed as a real source file or as an ambient one (a .d.ts shipped from a package, using declare const enum instead of a plain const enum). If app.ts compiles utils.ts directly as part of the same program, this is fine — the const enum’s values are inlined normally. But the moment utils.ts becomes a real, separately-published package (exactly what “a shared internal package” implies), consumers only ever see its .d.ts, where a const enum becomes declare const enum — and importing that under --isolatedModules (which per-file bundlers for fast builds routinely turn on) is a real, hard TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled. The type-checks-today, breaks-once-actually-published-and-consumed-for-real nature of this bug is exactly why const enum across a genuine package boundary is flagged as a pattern to avoid outright, rather than something to configure around case by case — the fix is a plain enum (accepting the runtime object) or an as const object (accepting the loss of a numeric reverse mapping), not a const enum at all.

Under --erasableSyntaxOnly, does a namespace containing only type declarations (namespace Ns { export interface Foo { x: number } }) produce TS1294?
Do legacy decorators (experimentalDecorators: true) produce TS1294 under --erasableSyntaxOnly?
A const enum is compiled by ts.transpileModule (a single-file transform, the same kind of engine that powers browser-based TypeScript playgrounds) rather than a real whole-program tsc build. What happens?
What does TS9013 (isolatedDeclarations) actually flag, and why doesn't the same code error under plain --declaration?
Of the four emitting paths in this lesson's table (tsc, tsx, esbuild, node --experimental-strip-types), which one rejects every non-erasable construct (enum, parameter properties, namespace-with-values, export =) outright, and why?