TypeScript config and tooling · 1 / 8
lesson 1

tsc vs bundler-driven type-checking

Your bundler stripped the types and shipped. Here's why types still need their own check.

~ 14 min read·lesson 1 of 8
0 / 8

You ship a small change. The dev server is happy, the build finishes in three seconds, and the deploy goes out. Ten minutes later a user reports a blank page. You open the console and see Cannot read properties of undefined (reading 'name'). The type system would have caught it — except the type system never ran. The bundler stripped the types and moved on, the way bundlers always do.

This lesson is about that gap. Once you see it, the rest of the toolchain makes sense.

What bundlers actually do with TypeScript

A bundler (Vite, esbuild, Webpack with ts-loader, swc) takes your TypeScript and turns it into JavaScript the browser can run. That part it does very well. What most modern bundlers do not do is check whether your types make sense.

They strip them. That is the technical word for it. The bundler reads const total: number = "hi", decides it knows how to delete the : number part, and emits valid JavaScript that does exactly what you wrote — assign the string "hi" to a variable called total. The TypeScript error never surfaces because the bundler never asked for one.

Here is the trade. Bundlers are fast because they only do parsing and transformation. Type-checking is a slower job — it has to walk every file, resolve every import, and reason about how types flow through your program. Most bundlers skip it on purpose so the dev loop stays under one second.

src/cart.ts
function getTotal(cart: { items: number[] }) {
return cart.itms.reduce((a, b) => a + b, 0);
/*           ^^^^ typo: should be cart.items */
}

const subtotal: number = "0";
/*                       ^^^ string assigned to a number */

Both lines are real type errors. Your editor underlines them in red. Vite's dev server starts. The page loads. Nothing complains, until a user clicks the button that calls getTotal and the page crashes.

The editor caught it because the editor runs the actual TypeScript compiler in the background. The bundler, in production mode, did not.

Watch out

"It runs in the browser" is not the same as "it type-checks". The bundler is a translator, not a proofreader.

check your understanding
You're using Vite. Your editor shows a red squiggle on a line. You run vite build and it succeeds. What just happened?

tsc is the type-checker

tsc is the TypeScript compiler, the one that ships with the typescript package. It does two jobs: it can emit JavaScript, and it can check types. You can ask for one without the other.

The flag for "check, don't emit" is --noEmit. That is the mode you want when a bundler is already handling the JavaScript output.

terminal
# check the whole project, do not write any output files
npx tsc --noEmit

# the output if everything is fine: nothing
# the output if something is broken: a list of errors with file paths

tsc --noEmit reads your tsconfig.json, walks every file the config includes, resolves every import, and reports every type error it finds. No JavaScript is produced. No dist/ folder appears. The only side effect is the exit code: 0 if clean, 1 if there were errors.

That exit code is the contract. CI tools read it. Pre-commit hooks read it. Your build script can read it. A non-zero exit is a hard fail.

terminal output
src/cart.ts:2:16 - error TS2551: Property 'itms' does not exist
on type '{ items: number[]; }'. Did you mean 'items'?

2   return cart.itms.reduce((a, b) => a + b, 0);
               ~~~~

Found 1 error in src/cart.ts:2

That is the message your bundler chose not to print. It tells you the file, the line, the column, the rule (TS2551), and a hint. Lesson 8 covers reading these in detail.

check your understanding
What does tsc --noEmit produce when every file is type-clean?

The two-process pattern

The standard setup runs two processes side by side. One bundles, one type-checks. They are independent. Either can fail without the other knowing.

package.json
{
"scripts": {
  "dev": "vite",
  "build": "vite build",
  "typecheck": "tsc --noEmit",
  "ci": "npm run typecheck && npm run build"
}
}

dev is the fast loop. Vite serves the app, your editor shows squiggles, you keep moving. build is the production output — JavaScript that runs in browsers. typecheck is the proofreader. ci is the gate: types first, then build. If types fail, the build never runs, and the CI step exits non-zero.

The order matters. You want the type-check to run before the build, not after. A failed type-check should stop the pipeline before you waste time on bundling and deploying broken code.

your codetsc --noEmitvite builddeploy
Two independent processes both have to succeed for a deploy to be safe.

For local feedback you can run both in parallel — tsc --noEmit --watch in one terminal tab and the dev server in another. The watch mode reruns the check on every save. It is slower than the editor, but it sees the whole project, including files the editor has not opened yet.

Tip

Pin typescript to the same version in package.json that your editor uses. Mismatches cause the editor to be quiet about errors that fail in CI.

check your understanding
Your CI script runs npm run build && npm run typecheck. The build succeeds, then typecheck fails. What is wrong with this script order?

Why CI is where this matters

A solo developer with a sharp editor catches most type errors before they hit git push. The problem is everyone else.

A teammate uses a different editor, with a slightly different TypeScript version, with the language server set to defer until idle. They edit a file, do not see the squiggle, commit, push. Your editor would have caught it. Theirs did not. The bundler does not care. The bug ships.

CI closes that gap. The CI machine runs tsc --noEmit in a fresh checkout, with the version of TypeScript pinned in package.json, on every pull request. There is no editor in the loop. There is no "I forgot to wait for the squiggle." Either the project type-checks, or the pull request is red.

.github/workflows/ci.yml
jobs:
check:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with: { node-version: 20 }
    - run: npm ci
    - run: npm run typecheck   # tsc --noEmit
    - run: npm run build       # only runs if typecheck passed

The &&-style chaining is built into the run-step semantics — a failing step stops the job. typecheck has to be green before build is attempted. That is the floor. Most teams add tests after that, but type-check first because it runs in seconds and the failure messages are precise.

A common follow-up question: should tsc --noEmit and the bundler share a config? Mostly yes. Both read tsconfig.json for include patterns, path aliases, and target. The bundler ignores most of the type-checking flags but uses the structural ones. Lesson 2 walks through which is which.

check your understanding
A teammate disabled their editor's TypeScript service to make their laptop faster. They commit code that has a real type error. Your project has CI running tsc --noEmit. What happens?
check your understanding
Why would a team run both vite build and tsc --noEmit instead of just one?
next lesson →
KeepLearningcertificate
for completing
TypeScript config and tooling
0 of 8 read