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) transpiles each file, stripping types. It is extremely 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.

isolatedModules: making single-file transpilation safe

Section titled “isolatedModules: making single-file transpilation safe”

Because bundlers transpile 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 and swc.

// 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.

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 it is the recommended setting for new projects.

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, and only rechecks what changed. It turns an O(whole-repo) check into an incremental one — the standard way to keep type-checking fast as a monorepo grows.

In a modern setup, which tool actually catches type errors?
Why is `isolatedModules` recommended when using a bundler?
What does `import type { User }` guarantee?
What problem do project references with `composite: true` solve?