Tags: web-dev concept

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 build

Type 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, null and undefined are assignable to everything and most of the value is gone
  • noUncheckedIndexedAccess — arr[0] becomes T | undefined, which is true and catches a real bug class. Also noisy, and worth it
  • noUnusedLocals — 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 downstream

One 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 use

unknown 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 check

TypeScript 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.