Type Checking in CI
Date: 2026-08-17
Running the type checker as a build gate, because nothing else does. Modern build tools strip types without checking them — so a project can be fully typed, build successfully, and ship type errors.
Type checking in CI runs the compiler in check-only mode as a pipeline step, failing the build on type errors.
The gap this closes
Fast transpilers don’t type-check. esbuild, SWC and Vite strip types and emit JavaScript, without verifying anything.
vite build strips types → SUCCEEDS
even with type errors
tsc --noEmit checks types → the actual gate
So a passing build proves nothing about types, which surprises people who assume the two are the same step. Editors check as you type, which hides the gap further — until someone commits with the editor closed, or a merge introduces an incompatibility neither branch had alone.
The command
tsc --noEmit--noEmit checks and produces no output, which is what you want when the bundler is doing the emitting.
- run: npm ci
- run: npm run typecheck # tsc --noEmit
- run: npm run lint
- run: npm test
- run: npm run buildType checking is usually the fastest of these to fail and the cheapest to run, so put it early — Pipeline Design.
Strictness
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noImplicitOverride": true
}
}strict— the umbrella. Without it,nullandundefinedare assignable to everything and most of the value is gonenoUncheckedIndexedAccess—arr[0]becomesT | undefined, which is true and catches a real bug class. Also noisy, and worth itnoUnusedLocals— dead code, caught
strict: false in a nominally-typed codebase is the most common way types under-deliver. Everything type-checks because everything is implicitly any.
Adopting it incrementally
Turning strict mode on across a large codebase produces thousands of errors, which nobody fixes.
1 strict: true, but only for new files
→ use overrides, or a separate
tsconfig for a subset
2 RATCHET — record the current error
count, fail if it rises
3 fix opportunistically, tighten the
ratchet as it falls
A ratchet is what makes this survivable. Blocking on a full fix means it never starts; blocking on regression means it improves steadily and never gets worse.
any is contagious
const data: any = await res.json()
data.user.name.length // no checking,
// anywhere downstreamOne any disables checking for everything derived from it, which is how a codebase ends up nominally typed and actually unchecked.
const data: unknown = await res.json()
// forces a narrowing check before useunknown is the honest alternative, and a lint rule banning explicit any is worth having — Linting, Type Systems.
Types are not runtime validation
The limitation worth being clear about:
const user: User = await res.json()
// ↑ an assertion, not a checkTypeScript checks nothing at runtime. At every external boundary — an API response, localStorage, a URL parameter, a form — the type is a claim about data you haven’t verified.
Validate at the boundary with a runtime schema validator, then let types carry it inwards — Type Safety Boundaries. Type checking in CI verifies your code is internally consistent; it says nothing about whether the data matches.
Beyond TypeScript
The same argument applies elsewhere:
PHP PHPStan, Psalm
Python mypy, pyright
Ruby Sorbet
CSS stylelint (a different job,
same pipeline position)
PHP’s static analysers are worth knowing about specifically, because PHP’s own type declarations are checked at runtime rather than ahead of it — so a static analyser is the only way to catch a type error before the request that triggers it — Reference - PHP.