open atlas
↑ Back to track
TypeScript type system, deep TS · 07 · 05

Type-checking vs transpilation: why your bundler doesn't catch type errors

esbuild/swc/babel transpile file-by-file and DO NOT type-check — a type error never stops the bundle. That model needs `isolatedModules` (no cross-file const enums, demands `export type`). The pattern: the bundler builds, and `tsc --noEmit` is the separate CI type gate.

TS Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

A teammate shipped code where const total: number = someUserId; — assigning a string to a number. Vite built it without a peep, the app deployed, and the bug only surfaced in production when arithmetic returned NaN. “But it’s TypeScript!” — yes, but Vite uses esbuild, which transpiles TypeScript and never type-checks it. The type annotation was deleted, not verified. Nothing in the build pipeline ran the type checker.

Two completely different jobs: checking vs transpiling

tsc does two things: it type-checks (analyses the whole program for type errors) and it emits JavaScript. Bundlers split these apart and keep only the second:

tooltype-checks?emitspeedsees other files?
tscyes (whole program)JS + .d.tsslowyes — cross-file analysis
esbuildnoJSvery fastno — one file at a time
swcnoJSvery fastno — one file at a time
babel (preset-typescript)noJSfastno — one file at a time
tsc --noEmityesnothingslowyes — the CI type gate

The reason bundlers are 10–100× faster than tsc is exactly that they don’t type-check. Type analysis requires building a graph of the whole program; transpilation just deletes type syntax token-by-token in one file. A type error is invisible to them — it’s just syntax they erase.

isolatedModules: making single-file transpilation safe

If your team uses esbuild or swc for emit, there’s a class of perfectly valid TypeScript that will silently produce wrong JavaScript — not an error, just a broken bundle. isolatedModules is the flag that catches those constructs at edit time instead of at runtime.

Because esbuild/swc/babel compile one file at a time with no knowledge of other files, some TypeScript constructs are unsafe to transpile that way — they need whole-program information. isolatedModules: true makes tsc flag exactly those constructs, so your code is portable to a single-file transpiler:

// with isolatedModules, these error in tsc:

// 1. A type-only re-export that LOOKS like a value re-export:
export { SomeType } from "./types";
//       ^^ TS1205-ish: a single-file compiler can't tell if SomeType is a
//          type (erase the export) or a value (keep it). Fix:
export type { SomeType } from "./types"; // now unambiguous — erase it

// 2. const enum across files — needs the enum's values inlined from another file:
import { Color } from "./enums";
const c = Color.Red; // const enum can't be inlined without cross-file info

isolatedModules doesn’t change emit; it restricts what you may write so that per-file transpilation produces correct output. The two big things it effectively forbids: const enum consumed across files (a single-file compiler can’t inline values it can’t see) and ambiguous type-only re-exports (forcing export type / import type).

Why this works

Why can’t a single-file transpiler handle export { SomeType } from "./types"? Because to emit correct JS it must decide whether SomeType is a type (erase the re-export entirely — types don’t exist at runtime) or a value (keep the re-export). Making that decision requires opening ./types and checking what SomeType is — cross-file knowledge the transpiler refuses to gather for speed. export type { SomeType } tells it unambiguously “this is a type, erase it”, which is why isolatedModules (and verbatimModuleSyntax) push you toward explicit type modifiers.

The pattern: bundler builds, tsc --noEmit gates

The production setup decouples the two jobs. The bundler produces the artifact fast; tsc --noEmit (often tsc -b --noEmit for monorepos) runs in parallel or in CI purely to check types — it emits nothing, just fails the build if a type is wrong:

// package.json scripts
{
  "scripts": {
    "build": "vite build",            // esbuild transpiles — NO type checking
    "typecheck": "tsc --noEmit",      // the real type gate — emits nothing
    "ci": "npm run typecheck && npm run build"
  }
}

verbatimModuleSyntax (TS 5.0) reinforces this: it makes import/export emit literal so a regular import is always kept and only import type/export type are erased. That predictability is what a transpiler needs — no guessing whether an import was “used as a value”. It supersedes the old importsNotUsedAsValues and addresses the same single-file concerns isolatedModules flags. (Note: tsc itself still does declaration emit — .d.ts generation — which a transpiler never does, so library builds keep tsc for types even when esbuild produces the JS.)

Complete the analogy

A transpiler like esbuild is to type-checking as a fast photocopier is to ___ — it reproduces the pages quickly but never reads them for mistakes.

Quiz

Your Vite app builds and deploys even though a file has a clear type error (assigning a string to a number). Why didn't the build fail, and how do you catch it?

Recall before you leave
  1. 01
    Why does a type error pass through an esbuild/swc/babel build, and what is the fix?
  2. 02
    What is isolatedModules, why is it required for single-file transpilers, and what does it forbid?
  3. 03
    What is the bundler-plus-tsc-noEmit pattern, and where does verbatimModuleSyntax / declaration emit fit?
Recap

You now know the division of labour that defines a modern TypeScript build: transpilers emit JS per-file and never check types, isolatedModules keeps your code safe for that model (no cross-file const enums, explicit export type), and tsc --noEmit is the gate that actually enforces type safety while the bundler handles speed. This closes the config/modules/build unit. Next, in real-world TypeScript, we apply all of it — common pitfalls where these config and emit subtleties bite teams in production code. Now when you see a type error that slipped into production through a Vite build, you know the missing piece: tsc --noEmit was never in the CI pipeline.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.