Type-Checking in CI
Advanced · 18 min read · ▶ live playground · ✦ checkpoint
CI is where tsc --noEmit stops being a personal habit and becomes a team contract. A real TypeScript project should not merge because the bundler produced JavaScript; it should merge only after the compiler, linter, type tests, runtime tests, and build all agree.
This is a tool-shaped lesson. The examples are reference scripts, CI workflow shapes, and terminal transcripts you would adapt to your own repository.
Make typecheck a required check
Section 1 promised that tsc --noEmit belongs in CI. The reason is still the same: TypeScript can check the full program and write no JavaScript, so the command behaves like a fast safety test for your type layer.
Give it a named script:
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint . --max-warnings=0",
"test:types": "vitest --typecheck",
"test:run": "vitest run",
"build": "vite build",
"quality": "npm run typecheck && npm run lint && npm run test:types && npm run test:run && npm run build"
}
}Replace vite build with your framework or bundler's build command. The shape matters more than the tool names:
typecheckproves the TypeScript program is coherent.lintenforces team policy such as no floating promises.test:typesprotects public type contracts.test:runexecutes runtime behavior.buildproves the production bundler can produce the app or package.
A local failure should look boring:
npm run typecheck> typecheck
> tsc --noEmit
src/pricing.ts:18:12 - error TS2322: Type '"enterprise"' is not assignable to type '"free" | "pro"'.That output is the point. CI should reject the same mistake before review turns into archaeology.
Split the bundler from the checker
A bundler build and a TypeScript check answer different questions.
The bundler asks, "Can I transform these entry points into runnable files?" Depending on the tool and configuration, it may transpile TypeScript syntax without performing a full project type check.
tsc --noEmit asks, "Is the project type-consistent under this tsconfig?" It walks the TypeScript program, follows imports and declarations, and reports diagnostics without writing output.
That split also makes failures easier to route:
npm run typecheck
npm run buildtypecheck failed:
Fix source types, declarations, or tsconfig inclusion.
build failed:
Fix bundler config, environment variables, asset imports, or production-only transforms.Do not hide tsc --noEmit inside build unless the project truly has no other way to run it. Separate scripts make local debugging, CI logs, and cache behavior clearer.
The full quality gate
A practical pull-request workflow runs the cheapest correctness checks before the most expensive production build:
name: quality
on:
pull_request:
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck -- --pretty false
- run: npm run lint
- run: npm run test:types
- run: npm run test:run
- run: npm run buildThe exact CI provider can change. The gate does not:
- Install from the lockfile.
- Run the compiler with no emit.
- Run typed lint policy.
- Run type tests for public API facts.
- Run unit tests for behavior.
- Run the production build last.
That order fails fast. If a renamed property breaks the typecheck, you learn before spending time on runtime tests and bundle output. If types pass but unit tests fail, the bug is behavior. If both pass but the build fails, the problem is likely packaging, environment, or bundler setup.
Keep the gate explicit even when your platform has a "build command" box. A single opaque command such as npm run quality is fine for local convenience. In CI, separate steps make logs searchable and let reruns focus on the broken job.
Caching without lying to yourself
Caching is a speed tool, not a correctness tool. A cold CI run must still be able to pass from source, lockfile, and config alone.
Start with dependency caching keyed by the package-manager lockfile. That avoids repeated downloads without pretending old dependencies are still valid after the lockfile changes.
Then consider TypeScript incremental state if your project is large enough to benefit. Put the cache file somewhere deliberate:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"incremental": true,
"tsBuildInfoFile": ".cache/tsc/ci.tsbuildinfo"
}
}Now the script can be:
{
"scripts": {
"typecheck": "tsc -p tsconfig.ci.json"
}
}Cache .cache/tsc only if your CI keys include the inputs that affect type checking: the lockfile, TypeScript config files, and source hash strategy your team trusts. If a cache ever makes results suspicious, delete it and require a clean run. Correctness must not depend on a warmed cache.
Project references from Section 18 are the larger version of the same idea. In a workspace, tsc -b --pretty false lets TypeScript understand package boundaries and reuse build information per project. That is better than one giant check when the repo has real packages.
The mature CI habit is boring and strict: install exactly, typecheck first, lint policy, type tests, runtime tests, build, and cache only what can be safely thrown away.
Next lesson moves from enforcing a typed project to creating one gradually: how to migrate JavaScript without turning the repo into one enormous unreviewable rewrite.
Checkpoint
Answer all three to mark this lesson complete