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.
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:
| tool | type-checks? | emit | speed | sees other files? |
|---|---|---|---|---|
tsc | yes (whole program) | JS + .d.ts | slow | yes — cross-file analysis |
esbuild | no | JS | very fast | no — one file at a time |
swc | no | JS | very fast | no — one file at a time |
babel (preset-typescript) | no | JS | fast | no — one file at a time |
tsc --noEmit | yes | nothing | slow | yes — 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 infoisolatedModules 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.)
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.
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?
- 01Why does a type error pass through an esbuild/swc/babel build, and what is the fix?
- 02What is isolatedModules, why is it required for single-file transpilers, and what does it forbid?
- 03What is the bundler-plus-tsc-noEmit pattern, and where does verbatimModuleSyntax / declaration emit fit?
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.