Skip to content

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 two-track build
  • 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 --noEmit does 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.

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:

CodebaseTypeScript 6TypeScript 7 (--checkers 4)Speed-up
VS Code125.7 s10.6 s11.9×
Sentry139.8 s15.7 s8.9×
Playwright12.8 s1.47 s8.7×
tldraw11.2 s1.46 s7.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 4 is the default, --checkers 8 pushes VS Code to 16.7× faster, and --singleThreaded turns it off when you want deterministic profiling. In --build mode, --builders N checks 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.ts output are printed in a stable order regardless of which thread saw them first. TypeScript 6.0 exposes this as --stableTypeOrdering so 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/typescript6 package, 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.

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 erasableSyntaxOnly
class 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 instead

Every 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);
ToolWhat it doesResult
tsc --noEmit --strictType-checks only0 errors — enum and parameter properties are completely legal TypeScript
tsc (real emit)Transpiles with a full understanding of the syntaxEmits a real IIFE for the enum and a real this.x = x; this.y = y; for the parameter property — runs fine
tsxTranspiles via esbuild, run immediatelyRuns fine — prints 0 1 2
node --experimental-strip-typesStrips type annotations only, transforms nothingSyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
esbuild (bundler-style transpile)Transpiles with understanding, like tscEmits 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-export
export { User } from "./user"; // ❌ ambiguous: is User a type or a value?

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 modules
import type { User } from "./user"; // erased entirely at emit
import { type User, save } from "./db"; // User erased, save kept

verbatimModuleSyntax: 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 code
export 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.

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 yet
Project 'core/tsconfig.json' is out of date because output file 'core/tsconfig.tsbuildinfo' does not exist
Building project 'core/tsconfig.json'...
Project 'app/tsconfig.json' is out of date because output file 'app/tsconfig.tsbuildinfo' does not exist
Building project 'app/tsconfig.json'...
$ tsc -b app --verbose # 2) rerun immediately, nothing changed
Project '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.ts
Project '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);
Open in TS Playground

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.

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.

ApproachBenefitCost
tsc emitCorrect transformation of every construct, including decorators and enumsSlower 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 enoughA 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-typesZero build step at all for compatible codeRejects an entire category of legitimate, working TypeScript (enum, parameter properties, namespace with code) outright
erasableSyntaxOnlyMakes “type-checks” and “the stripper will run it” the same guaranteeForces rewriting constructs that are otherwise perfectly good TypeScript, purely for stripper compatibility
tsc --build + project referencesReal 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
  • “It type-checks” means any tool can run the resulting JavaScript. The verified table above shows four tools disagreeing on identical input: tsc --noEmit says “fine” for enum and parameter properties, but node --experimental-strip-types refuses to run the exact same file. Type-checking and runtime-runnability are different questions unless erasableSyntaxOnly ties them together.
  • esbuild transpiles 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 runtime SyntaxError — “esbuild supports X” often means “esbuild supports X at the target you configured,” not unconditionally.
  • A tsc -b run 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. --stopBuildOnErrors restores 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 is TS9013 (“Expression type can’t be inferred”), not the more generic-sounding code it’s easy to assume.
  • Does generated code add enum, a namespace with real logic, or a parameter property to a codebase that’s meant to run under Node’s --experimental-strip-types or a strip-only tool? tsc --noEmit alone will not warn about this — only erasableSyntaxOnly (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 -b CI 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 the error TSxxxx line 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 isolatedDeclarations for fast parallel .d.ts emit? That specific gap is exactly what TS9013 exists 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?

In a modern setup, which tool actually catches type errors?
What is the main thing TypeScript 7 changed about tsc?
A lint plugin does import ts from "typescript" and breaks after upgrading to TypeScript 7. Why?
Why does erasableSyntaxOnly reject enum and constructor parameter properties?
What does import type { User } guarantee?
Verified across four tools, which one rejects this file outright, and why don't the others catch the same problem?
enum Color { Red, Green }

class Point {
  constructor(public x: number, public y: number) {}
}
A two-project tsc -b app build has a real type error in core (a dependency of app). By default, what happens to the app project in that same run?