The TypeScript Toolchain: tsc, Bundlers, Linters, and Editors

Understand the distinct roles played by tsc, bundlers like Vite, ESLint, Prettier, and the editor's language service in a real build pipeline, and learn how allowJs/checkJs/JSDoc let a JavaScript project adopt TypeScript incrementally.

It's tempting to think of 'compiling TypeScript' as one step, but a production build pipeline actually runs several genuinely separate tools, each solving a different problem — and conflating them is one of the most common sources of confusion for developers new to the ecosystem. Knowing which tool does what, and which ones can be skipped in which situations, is the difference between a fast, well-understood build and a slow, mysterious one.

Four separate jobs: type checking, transpilation, bundling, minification

Type checking is the process of verifying your code against TypeScript's type system — catching a string passed where a number was expected, a missing property, an impossible narrowing — without producing any runtime-executable output at all. It's purely a static analysis pass; a type-checked program and an un-type-checked program compile to identical JavaScript, because types carry no runtime information.

Transpilation is the separate process of converting TypeScript syntax (and newer JavaScript syntax) into plain JavaScript a target runtime can execute — stripping type annotations, downleveling ?? or optional chaining for older targets, converting enum and decorators into their runtime equivalents. Critically, transpilation does not require type checking to succeed, or even to run at all: a file with a genuine type error can still transpile to valid, runnable JavaScript, because the transpiler only looks at syntax, not types.

Bundling is a third, unrelated job: combining many separate module files into one (or a few) output files, resolving the import graph, so a browser doesn't have to make hundreds of individual network requests for every module. Minification is a fourth job layered on top of bundling's output: renaming variables to shorter names, removing whitespace and comments, and applying other size-reducing transforms to the already-bundled JavaScript, purely to shrink file size for faster downloads — it changes nothing about behavior.

The reason this distinction matters in practice is that modern build tools deliberately split these jobs across different tools for speed: tsc alone can do all four historically (well, the first two plus a weak form of bundling via namespaces), but it's slow at transpilation compared to purpose-built tools written in Go or Rust. A fast pipeline typically uses tsc --noEmit purely for type checking, and hands transpilation and bundling to esbuild or swc (both far faster than tsc's own emit, because they skip type checking entirely and just strip types syntactically).

tsc: the reference compiler and type checker

tsc is the official TypeScript compiler and the reference implementation of the type system — every other fast transpiler (esbuild, swc, Babel's TypeScript preset) intentionally implements only the syntax-stripping part of what tsc does, not the type checking, because full type checking requires resolving the entire program's types, which is inherently slower than looking at one file at a time.

Run with tsc --noEmit, it performs type checking only and produces no output files — this is the command you run in CI or as a pre-commit check, specifically because you don't want its emitted JavaScript, only its verdict on whether the types are sound. Run without --noEmit, it also transpiles and emits .js (and optionally .d.ts) files, which is the right choice for a library whose build doesn't need bundling, but the wrong choice for an app already using a dedicated bundler.

tsc --watch and "incremental": true in tsconfig.json both exist to make repeated type checks faster: watch mode keeps the compiler process alive and re-checks only what changed, while incremental mode caches type information to a .tsbuildinfo file between separate tsc invocations (useful in CI, where the process doesn't stay alive between runs).

Bundlers: Vite and the esbuild/type-check split

Vite is a build tool, not a type checker. During development, Vite transforms each TypeScript file to JavaScript on the fly using esbuild, which strips type annotations purely syntactically and does not verify they were correct — this is why you can have a real type error in a file and still see the app run fine in the Vite dev server, only to be surprised when tsc --noEmit (run separately, often in CI) reports the error.

This split is deliberate and is the industry-standard pattern: vite build produces a fast, bundled, minified production build without ever fully type-checking your program, while a separate tsc --noEmit script — run in CI, in a pre-commit hook, or in the editor's live diagnostics — is what actually enforces type correctness. Treating a successful vite build as proof your types are correct is a common and costly misunderstanding.

Source maps are the piece that ties the fast, transformed/minified output back to your original source for debugging: a .map file (or inline sourceMappingURL comment) records how each position in the bundled, minified JavaScript corresponds to a position in your original .ts file, so browser devtools and error-reporting tools can show you a stack trace against your real source instead of a wall of minified, unreadable output.

Linting, formatting, and editor integration

ESLint (with the typescript-eslint parser and plugin) checks code *style and pattern* correctness — unused variables, inconsistent equality checks, disallowed patterns — which is a different axis entirely from TypeScript's type correctness. A file can be perfectly well-typed and still fail lint rules (an unused import), or pass every lint rule and still have a type error (linting doesn't check types by default, though typescript-eslint's 'type-aware' rules can opt into using the type checker for a subset of rules).

Prettier is narrower still: it only reformats code (indentation, quote style, line length) and deliberately has no opinion on correctness at all — it's common to run ESLint for correctness/style rules and Prettier for formatting, with ESLint's own formatting rules turned off to avoid the two fighting each other.

VS Code's TypeScript integration runs its own instance of the TypeScript language service in the background, which is how you get red squiggles, hover-for-type, and autocomplete live as you type — this is the same type checker as tsc, just running interactively and incrementally rather than as a one-shot command. It's worth knowing that the editor's live diagnostics and a project's actual tsc --noEmit CI check can occasionally disagree if the editor is using a different TypeScript version than the project's installed one — always trust the project's own installed version, configured via VS Code's 'select TypeScript version' command, over whatever ships with the editor by default.

Typing plain JavaScript: allowJs, checkJs, and JSDoc

You don't need to rewrite a JavaScript codebase in .ts files to get type checking — "allowJs": true in tsconfig.json lets .js files be included in a TypeScript program alongside .ts files, and "checkJs": true turns on full type checking for those .js files too (without it, allowJs only lets TypeScript resolve and bundle them, without checking their contents).

Per-file, // @ts-check at the top of a single .js file opts just that file into checking, without turning it on project-wide — a good way to migrate one file at a time rather than flipping a switch that suddenly reports hundreds of pre-existing errors across an entire codebase.

Since plain .js files can't use TypeScript's : type annotation syntax, type information for parameters, return types, and variables is instead written as JSDoc comments (/** @param {string} name */, /** @returns {number} */, /** @type {SomeInterface} */) — TypeScript's checker reads and enforces these exactly as if they were inline type annotations, which is what makes checkJs a genuinely capable type-checking mode and not just a cosmetic add-on.

This combination — allowJs, checkJs (or per-file @ts-check), and JSDoc — is the standard incremental-adoption path for a large existing JavaScript codebase: turn on checking file by file or directory by directory, fix what surfaces, and only convert files to actual .ts syntax later (or never, for files that work fine as checked JavaScript) rather than attempting a risky big-bang rewrite.

Separating type checking from build in package.json scripts

Each script does exactly one job — vite build never blocks on a type error by itself, which is why type-check is listed as its own explicit CI step rather than assumed to happen inside build.

{
  "scripts": {
    "type-check": "tsc --noEmit",
    "lint": "eslint . --max-warnings 0",
    "format": "prettier --check .",
    "build": "vite build",
    "ci": "npm run type-check && npm run lint && npm run format && npm run build"
  }
}

Code Examples

Even though this is a plain .js file with no TypeScript syntax at all, the @ts-check directive plus JSDoc annotations give the TypeScript language service enough information to catch the misuse on the last line, both in the editor and if this file is included in a checkJs-enabled project.

Common mistake: Forgetting the leading // @ts-check comment (or project-wide checkJs) means the file is parsed for imports/exports but never actually type-checked — the JSDoc annotations become documentation only, silently providing no safety.

allowJs plus checkJs brings every .js file under src into full type checking alongside any .ts files already present, letting the two coexist in one project during a gradual migration; strict is deliberately left off at first since a large existing JS codebase will otherwise surface an overwhelming number of errors at once — it gets turned on later, once obvious issues are cleared.

// @ts-check

/**
 * @param {number} celsius
 * @returns {number}
 */
function celsiusToFahrenheit(celsius) {
  return celsius * (9 / 5) + 32;
}

celsiusToFahrenheit("boiling"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "target": "ES2020"
  },
  "include": ["src"]
}

Where each tool sits in a real build pipeline

Four independent stages. A successful vite build only proves the syntax-transpile/bundle/minify stages succeeded -- it says nothing about whether tsc --noEmit would pass.

source (.ts / .tsx / checked .js)
        |
   +----+-----------------------+
   |                            |
[ESLint + Prettier]      [tsc --noEmit]
 (style / patterns)      (type checking only,
  runs independently,     no output emitted,
  can run in parallel)    fails CI on type errors)
   |                            |
   +-------------+--------------+
                 |
         [esbuild / swc via Vite]
         (fast syntax-only transpile:
          strips types, downlevels syntax
          -- does NOT verify types)
                 |
            [bundling]
         (combine modules into
          one or few output files)
                 |
            [minification]
         (shrink bundled output;
          + emit source maps)
                 |
            dist/ (ship this)

Important Rules

Never assume a successful bundler build (vite build, webpack, etc.) means your types are correct — run tsc --noEmit as its own explicit step, in CI and ideally pre-commit.

Use allowJs + checkJs (or per-file @ts-check) to add type safety to an existing JavaScript codebase gradually, rather than requiring a full rewrite to .ts before getting any benefit.

Keep ESLint responsible for style/pattern rules and Prettier responsible for formatting; don't let both fight over the same formatting concerns.

Pin the editor's TypeScript version to the project's installed version, not the editor's bundled default, so live diagnostics match what CI will report.

Where this shows up

A very common early-career confusion is a developer seeing their Vite dev server run cleanly and concluding 'TypeScript says this is fine,' only for the team's CI to fail on a type-check step they didn't know existed as a separate command. Large-scale JS-to-TS migrations at real companies almost always follow the allowJs/checkJs incremental path described here — a big-bang rewrite of a large codebase is rarely attempted because the risk of introducing regressions across thousands of files at once outweighs the benefit of a 'clean' all-at-once conversion.

// relying on npm run build (vite build) passing as the sign-off that a PR has no type errors

// running tsc --noEmit as a required, separate CI check alongside the build esbuild/swc-based bundlers strip TypeScript syntax without checking it, so a file with a genuine type error can still produce a working, bundled, minified build — the error only surfaces from an actual type-checking pass.

[multiple-choice] Which of these statements is accurate about tsc versus esbuild/swc-based transpilation? A. They do exactly the same thing, just at different speeds B. esbuild/swc check types but don't emit JavaScript; tsc emits JavaScript but doesn't check types C. esbuild/swc strip TypeScript syntax without checking types; tsc can do both type checking and emission D. tsc cannot be used for type checking, only esbuild can

C. esbuild/swc strip TypeScript syntax without checking types; tsc can do both type checking and emission — Fast transpilers like esbuild and swc intentionally implement only syntax-stripping, not the full type checker, for speed; tsc is the reference implementation capable of both checking and emitting.

[true-false] Minification and bundling are the same step in a build pipeline. A. True B. False

B. False — Bundling combines modules into fewer files; minification is a separate transform applied to the bundled output that shrinks its size (renaming, removing whitespace) without changing which files exist.

[multiple-choice] What does adding // @ts-check to the top of a plain .js file do? A. Converts the file to TypeScript syntax automatically B. Enables full type checking for just that file, using JSDoc annotations for type information C. Disables type checking for that file only D. Has no effect without also renaming the file to .ts

B. Enables full type checking for just that file, using JSDoc annotations for type information — @ts-check opts a single JavaScript file into TypeScript's type checker without requiring project-wide checkJs or a rename to .ts, reading type information from JSDoc comments.

A teammate says: 'I ran npm run build and it succeeded, so there are no type errors in this PR.' Explain, in one or two sentences, why that reasoning can be wrong, and name the command that would actually confirm it.

vite build typically transpiles via esbuild, which strips TypeScript syntax without checking types, so a build can succeed even with real type errors present. Running tsc --noEmit separately is what actually verifies type correctness.

Think about what tool vite build actually uses to transform .ts files, and whether that tool checks types.