Build & Emit
Checking and emitting are different tools’ jobs
Section titled “Checking and emitting are different tools’ jobs”The module opened with the idea that tsc does two jobs — check and emit. In a modern setup those jobs are usually done by different tools, and understanding why makes the config obvious.
flowchart LR src["source .ts"] --> bundler["bundler (esbuild/swc/vite) strips types, emits JS fast"] src --> tsc["tsc --noEmit checks types"] bundler --> out["shipped JavaScript"] tsc --> ci["pass/fail in CI"]
- The bundler (esbuild, swc, Vite/Rolldown) transpiles each file, stripping types. It is fast because it never checks anything — it looks at one file at a time and deletes the types.
tsc --noEmitdoes the whole-program type check and produces no output. This is what actually catches your bugs, and you run it in CI (and your editor runs it continuously).
Ship what the bundler emits; trust what tsc reports. noEmit tells tsc to be a pure checker. The split used to be justified by speed — “tsc is too slow to be in the hot path” — and that argument has mostly evaporated, as the next section explains. The split still stands for a better reason: bundlers do things tsc never will (tree-shaking, code-splitting, asset handling), and the checker should not care how you bundle.
The native compiler (TypeScript 7)
Section titled “The native compiler (TypeScript 7)”TypeScript 7.0 (July 2026) is the compiler and language server rewritten in Go. From your side almost nothing changes: npm install -D typescript installs it, the binary is still tsc, tsconfig.json is the same file, and it reports the same errors. What changes is the clock:
| Codebase | TypeScript 6 | TypeScript 7 (--checkers 4) | Speed-up |
|---|---|---|---|
| VS Code | 125.7 s | 10.6 s | 11.9× |
| Sentry | 139.8 s | 15.7 s | 8.9× |
| Playwright | 12.8 s | 1.47 s | 8.7× |
| tldraw | 11.2 s | 1.46 s | 7.7× |
Memory drops 6–26% at the same time, and the editor reports its first error in the VS Code repo in about 1.3 s instead of 17.5 s. Where the speed comes from, and what it means for you:
- Parallel type-checking. The checker splits the program across worker threads:
tsc --checkers 4is the default,--checkers 8pushes VS Code to 16.7× faster, and--singleThreadedturns it off when you want deterministic profiling. In--buildmode,--builders Nchecks independent project references in parallel. - Rebuilt
--watch. File watching now uses a native watcher (the Parcel watcher ported to Go) instead of polling, so watch mode on a large repo no longer pins a CPU core. - Deterministic type ordering. Types in error messages and
.d.tsoutput are printed in a stable order regardless of which thread saw them first. TypeScript 6.0 exposes this as--stableTypeOrderingso you can diff the two compilers’ output before switching. - No programmatic API in 7.0.
import ts from "typescript"for custom transformers, linters, and language-tool plugins does not exist yet — 7.1 will ship a new one. Until then, tools that need the API (typescript-eslint, Volar) run TypeScript 6 via the@typescript/typescript6package, and you can install both side by side:
{ "devDependencies": { "typescript": "^7.0.2", "@typescript/typescript6": "^6.0.2" }}- Editors. VS Code ships the TypeScript 7 language server (a “Disable TypeScript 7 Language Server” command falls back to 6 if needed); anything LSP-based works with it. Frameworks whose template checking embeds the compiler — Vue, Svelte, Astro, Angular — keep using 6.0 in the editor until the 7.1 API lands, so a hybrid setup (7 for
tsc, 6 for the editor) is normal in those projects for now.
Two behavioural changes are worth knowing: JSDoc-only JavaScript loses the Closure-style forms (@enum, a bare ? type, postfix !, @class), and template-literal infer now splits strings by Unicode code point, so HeadTail<"😀abc"> is ["😀", "abc"] rather than a pair of surrogate halves.
Run TypeScript without a build
Section titled “Run TypeScript without a build”Node 22.6+ (default since 23.6, and in every Node 24 LTS) runs node app.ts directly by stripping the type annotations — no tsc, no bundler, no loader. Deno and Bun have done this for years. The catch is the word stripping: the runtime deletes types; it does not transform syntax. Anything TypeScript adds that needs real JavaScript emitted — enum, namespace with code in it, class parameter properties, import x = require() — is rejected.
erasableSyntaxOnly: true (5.8) makes tsc enforce exactly that rule at check time, so the code you type-check is the code Node can run:
// with erasableSyntaxOnlyclass Point { constructor(public x: number, public y: number) {} // ❌ parameter properties need emit (TS1294)}enum Color { Red, Green } // ❌ enums need emit
class Point2 { readonly x: number; // ✅ an ordinary field assignment instead — constructor(x: number) { this.x = x; } // readonly on a parameter property still needs emit too}const Color2 = { Red: "red", Green: "green" } as const; // ✅ a const object insteadEvery modifier on a parameter — public, private, readonly — makes it a parameter property, and parameter properties always need this.x = x emitted for you; none of them are erasable. constructor(readonly x: number) {} still fails with TS1294 under erasableSyntaxOnly, and Node’s --experimental-strip-types rejects it too (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: TypeScript parameter property is not supported in strip-only mode) — the fix is always to write the field and the assignment out by hand, as Point2 does above.
Pair it with rewriteRelativeImportExtensions and you can develop against .ts files in place and still emit a clean .js package with tsc for publishing.
One file, four tools: what each one actually does with non-erasable syntax
Section titled “One file, four tools: what each one actually does with non-erasable syntax”“Compiles” hides a real difference between tools. Running the exact same file — an enum and a class with a parameter property — through tsc, tsx, node --experimental-strip-types, and esbuild (verified in scratch) makes the difference concrete:
enum Color { Red, Green }
class Point { constructor(public x: number, public y: number) {}}
const p = new Point(1, 2);console.log(Color.Red, p.x, p.y);| Tool | What it does | Result |
|---|---|---|
tsc --noEmit --strict | Type-checks only | 0 errors — enum and parameter properties are completely legal TypeScript |
tsc (real emit) | Transpiles with a full understanding of the syntax | Emits a real IIFE for the enum and a real this.x = x; this.y = y; for the parameter property — runs fine |
tsx | Transpiles via esbuild, run immediately | Runs fine — prints 0 1 2 |
node --experimental-strip-types | Strips type annotations only, transforms nothing | SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode |
esbuild (bundler-style transpile) | Transpiles with understanding, like tsc | Emits an equivalent IIFE, runs fine |
Four tools, three different outcomes for identical input, and the type checker alone can’t distinguish any of them — tsc --noEmit says “fine” regardless of which of the other three will actually run the result. This is what erasableSyntaxOnly closes: it makes the checker itself refuse anything that only tsc/tsx/esbuild-style transformation (not pure stripping) can handle, so a clean check under that flag really does predict Node’s stripper will accept the file.
Decorators split the remaining three tools further still. The same class with a @logged method decorator, transpiled by esbuild at its default settings (an esnext-ish target) and then run directly with plain node, fails with SyntaxError: Invalid or unexpected token — decorator syntax passed through untouched, and V8 doesn’t parse it natively. tsx runs the identical source successfully, because it configures esbuild’s --target low enough to force decorator lowering rather than pass-through (verified in scratch: esbuild --target=es2022 on the same file produces real, if verbose, lowering helpers and runs correctly). The lesson isn’t “esbuild is broken” — it’s that “transpiles with esbuild” is not one fixed behavior; it depends on the target the caller configured, and tsx’s defaults and a bare esbuild invocation are not the same tool in practice.
isolatedModules: making single-file transpilation safe
Section titled “isolatedModules: making single-file transpilation safe”Because bundlers and type-strippers work one file at a time, they cannot see types from other files. A few TypeScript constructs are ambiguous under single-file transpilation — most importantly, re-exporting a type looks identical to re-exporting a value. isolatedModules: true makes tsc flag anything a single-file transpiler could get wrong, so your code stays compatible with esbuild, swc, and Node’s stripper.
// @skip-verify illustrative — "./user" is not a real module// with isolatedModules, tsc forces you to be explicit here:export type { User } from "./user"; // ✅ clearly a type re-exportexport { User } from "./user"; // ❌ ambiguous: is User a type or a value?Type-only imports
Section titled “Type-only imports”The fix above is part of a broader feature: type-only imports and exports. Marking an import as type-only tells the compiler it exists purely for type information and must be erased — never emitted as a runtime import.
// @skip-verify illustrative — "./user" and "./db" are not real modulesimport type { User } from "./user"; // erased entirely at emitimport { type User, save } from "./db"; // User erased, save keptverbatimModuleSyntax: true enforces this discipline: any import that is not marked type is emitted verbatim as a runtime import. It removes a whole category of surprises where the compiler silently dropped or kept an import, and tsc --init turns it on for you.
Splitting emit from checking the other way: noCheck and isolatedDeclarations
Section titled “Splitting emit from checking the other way: noCheck and isolatedDeclarations”noEmit is “check, don’t emit”. Since 5.6 there is the mirror image: noCheck is “emit, don’t check” — tsc runs only the syntactic passes it needs to produce output. On its own it is a slower esbuild, but combined with isolatedDeclarations (5.5) it becomes useful: if every export carries an explicit type, .d.ts files can be generated per file without the checker, which means declaration emit can run in parallel with type-checking, or be done by a bundler plugin entirely.
// @skip-verify needs --isolatedDeclarations, which this course's default check flags do not enable -- see prose for the real command and codeexport function parse(input: string) { return JSON.parse(input);}Compiled with --isolatedDeclarations --declaration --noEmit added, that’s error TS9013: Expression type can't be inferred with --isolatedDeclarations (verified in scratch — not TS9007, a plausible-looking code that turns out to be wrong for this exact case; always check the real code rather than trust a remembered one). Adding the explicit return type this course’s earlier lessons already recommend for public APIs fixes it with zero other changes:
export function parse(input: string): unknown { return JSON.parse(input);}The cost is annotating your public API — which, for a library, you should be doing anyway.
Project references for monorepos
Section titled “Project references for monorepos”In a large repo with several packages, type-checking everything from scratch on every change is slow. Project references let you split the codebase into independently buildable projects with declared dependencies:
{ "references": [{ "path": "../core" }], "compilerOptions": { "composite": true }}With composite: true, tsc --build checks each referenced project once, caches the result in .tsbuildinfo (always written since 5.6), and only rechecks what changed. Since 5.6 a build continues past errors in an upstream project so you see every failure in one run — pass --stopBuildOnErrors for the old fail-fast behaviour — and TypeScript 7 checks independent projects concurrently with --builders. It turns an O(whole-repo) check into an incremental, parallel one.
A real two-project build (app referencing core, verified in scratch) shows the caching and the error-propagation both, across three runs of the identical tsc -b app --verbose:
$ tsc -b app --verbose # 1) clean build, nothing cached yetProject 'core/tsconfig.json' is out of date because output file 'core/tsconfig.tsbuildinfo' does not existBuilding project 'core/tsconfig.json'...Project 'app/tsconfig.json' is out of date because output file 'app/tsconfig.tsbuildinfo' does not existBuilding project 'app/tsconfig.json'...
$ tsc -b app --verbose # 2) rerun immediately, nothing changedProject 'core/tsconfig.json' is up to date because newest input 'core/src/index.ts' is older than output 'core/tsconfig.tsbuildinfo'Project 'app/tsconfig.json' is up to date because newest input 'app/src/index.ts' is older than output 'app/dist/index.js'
$ tsc -b app --verbose # 3) after introducing a real type error in core/src/index.tsProject 'core/tsconfig.json' is out of date because output 'core/tsconfig.tsbuildinfo' is older than input 'core/src/index.ts'Building project 'core/tsconfig.json'...core/src/index.ts(2,3): error TS2322: Type 'number' is not assignable to type 'string'.Project 'app/tsconfig.json' is out of date because output 'app/dist/index.js' is older than input 'core'Building project 'app/tsconfig.json'...Run 2 rebuilds nothing at all — every project reports “up to date” from .tsbuildinfo timestamps alone. Run 3 is the “continues past errors” default in action: core fails, but tsc -b still attempts app afterward (using core’s last good output) instead of stopping — that’s what surfaces every downstream failure in one pass on a real monorepo, rather than one project at a time across repeated invocations. Adding --stopBuildOnErrors to that same broken state changes the outcome: core still fails identically, but app is explicitly reported as skipped (“app/tsconfig.json can’t be built because its dependency ‘core’ has errors”) rather than attempted — the old fail-fast behavior, useful when a downstream build attempt against known-broken output would just be noise.
strict alone: does the checker reject this on its own?
enum Color { Red, Green }
class Point { constructor(public x: number, public y: number) {}}
const p = new Point(1, 2);console.log(Color.Red, p.x, p.y); Click Check types — strict (always on here) reports zero errors. enum and a parameter property are completely ordinary, legal TypeScript on their own; nothing about strict cares whether a runtime can run the result unmodified. That’s exactly the gap erasableSyntaxOnly closes — this course’s own CLI-verified proof above shows the identical file rejected outright (TS1294) the moment that flag is on, and rejected again by node --experimental-strip-types regardless of any flag at all. A clean check in this widget proves the syntax is legal; it says nothing about whether the file you’re about to run with a type-stripping runtime will actually run.
Mental Model
Section titled “Mental Model”Every tool in this lesson sits somewhere on one axis: does it understand TypeScript syntax well enough to transform it, or does it only know how to delete type annotations? tsc, tsx, and esbuild (properly configured) are transformers — they can turn an enum into a real object, a parameter property into a real assignment, a decorator into real helper calls. Node’s --experimental-strip-types is a deleter — it removes exactly the tokens that are type syntax and refuses to touch anything else, which is precisely why enum and parameter properties (real runtime constructs wearing TypeScript syntax) are rejected rather than transformed. erasableSyntaxOnly is the checker borrowing the deleter’s rule for itself, so “passes the check” and “the stripper will accept this file” become the same claim instead of two unrelated ones. Project references apply the identical checked/not-checked question at the scale of a whole build graph: tsc -b’s “up to date” is a real claim about what still needs checking, backed by .tsbuildinfo, not a guess.
Trade-offs
Section titled “Trade-offs”| Approach | Benefit | Cost |
|---|---|---|
tsc emit | Correct transformation of every construct, including decorators and enums | Slower than a single-file transpiler for large codebases without --checkers/project references |
tsx / esbuild (configured for it) | Fast, single-file transpilation, real decorator/enum lowering when the target is set low enough | A bundler’s default target may not lower everything — verified above, a bare esbuild call left decorators broken at runtime until --target=es2022 was added explicitly |
node --experimental-strip-types | Zero build step at all for compatible code | Rejects an entire category of legitimate, working TypeScript (enum, parameter properties, namespace with code) outright |
erasableSyntaxOnly | Makes “type-checks” and “the stripper will run it” the same guarantee | Forces rewriting constructs that are otherwise perfectly good TypeScript, purely for stripper compatibility |
tsc --build + project references | Real incremental, parallel checking across a monorepo, with errors surfacing in one pass by default | --stopBuildOnErrors needed explicitly to restore fail-fast; without it a broken upstream project’s errors and a skipped downstream project both show up together, which needs reading carefully |
Common Misconceptions
Section titled “Common Misconceptions”- “It type-checks” means any tool can run the resulting JavaScript. The verified table above shows four tools disagreeing on identical input:
tsc --noEmitsays “fine” forenumand parameter properties, butnode --experimental-strip-typesrefuses to run the exact same file. Type-checking and runtime-runnability are different questions unlesserasableSyntaxOnlyties them together. esbuildtranspiles TypeScript decorators correctly, since it “supports TypeScript.” Only at a low enough--target. At esbuild’s own default, this course’s scratch test left decorator syntax completely untouched, producing a runtimeSyntaxError— “esbuild supports X” often means “esbuild supports X at the target you configured,” not unconditionally.- A
tsc -brun that reports an error in an upstream project stops there. By default it doesn’t — it continues into dependent projects using their last good build, surfacing every failure across the whole graph in one pass.--stopBuildOnErrorsrestores the old fail-fast behavior; without it, “app” is still attempted even when “core” (its dependency) just failed. isolatedDeclarations’s errors are about missing types in general. They’re specifically about inferred return/property types the checker can’t reconstruct without running full inference — verified above, the real code isTS9013(“Expression type can’t be inferred”), not the more generic-sounding code it’s easy to assume.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does generated code add
enum, anamespacewith real logic, or a parameter property to a codebase that’s meant to run under Node’s--experimental-strip-typesor a strip-only tool?tsc --noEmitalone will not warn about this — onlyerasableSyntaxOnly(or actually trying to run the file with the target stripper) catches it. - Was a decorator added to a file, with a build step change assumed to “just work” because “the bundler handles TypeScript”? Verify the bundler’s configured target actually lowers decorators — a default
esnext-ish target frequently does not, and the failure only shows up at runtime, not at build time. - Does a generated
tsc -bCI step treat any non-zero exit code as “everything failed,” when the real log shows one project’s real error and other projects merely skipped or rebuilt from stale upstream output? Read which project actually reported theerror TSxxxxline before assuming the whole graph is equally broken. - Is a function meant to ship in a library’s public API left without an explicit return type, in a project that turns on
isolatedDeclarationsfor fast parallel.d.tsemit? That specific gap is exactly whatTS9013exists to catch — verify the real code before describing the fix in a review comment.
🐛 Spot the bug
export function createLogger(prefix: string) {return { info: (msg: string) => console.log(`[${prefix}] ${msg}`), error: (msg: string) => console.error(`[${prefix}] ERROR: ${msg}`),};}
export const enum Level {Info,Error,}Show the answer
Two separate problems, and tsc --noEmit --strict reports zero errors for both. First, createLogger has no explicit return type — under plain strict that’s fine (the checker infers it), but the moment this library turns on isolatedDeclarations for fast, parallel .d.ts emit, this exact shape fails with TS9013, because the inferred return type is an object literal the checker would have to fully evaluate, not read off the syntax. Second, const enum is even more restrictive than a regular enum: it’s fully inlined at every use site within the same program, which means a const enum exported across a package boundary can silently go stale if the consumer and the library are compiled separately (isolated .d.ts generation, incremental builds) — and it’s rejected outright under erasableSyntaxOnly, exactly like a regular enum, since it’s still non-erasable syntax from the runtime’s point of view. Reviewing generated library code means checking both: does every export have an explicit type for isolatedDeclarations, and does any enum/const enum need to become an as const object instead for a project that runs under a stripper?