TypeScript in CI
Type checking as a required gate, keeping it fast as the codebase grows, caching and incremental builds, and failing on the errors that matter.
Type checking as a required gate, keeping it fast as the codebase grows, caching and incremental builds, and failing on the errors that matter.
The pipeline takes eleven minutes. It fails on a formatting
difference twenty seconds in, so nobody sees the type error that
was waiting behind it. Two pushes later, someone adds
--no-verify to their muscle memory and stops running anything
locally at all.
A gate that is slow gets bypassed, and one that reports the wrong thing first wastes the run. By the end of this lesson you will have the checks from this course wired into a pipeline that catches what matters, in an order that is useful, fast enough that nobody works around it.
{
"scripts": {
"check": "tsc --noEmit",
"lint": "eslint .",
"format:check": "prettier --check .",
"test": "vitest run",
"build": "vite build"
}
}Each answers a different question, and none subsumes another — which is the point the linting lesson made and the reason to run all four.
The one that surprises people: the build does not type-check.
Vite, esbuild and swc strip types and emit. A green build with a
broken type is entirely normal, so tsc --noEmit is a separate
step or it does not happen.
name: ci
on: [push, pull_request]
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 format:check
- run: npm run lint
- run: npm run check
- run: npm run test
- run: npm run buildnpm ci rather than npm install: it installs exactly the lock
file and fails if it disagrees with package.json, so CI builds
what you tested.
Separate steps, so the log names the failure rather than giving one red cross and a scroll.
Bad — the cheapest check first, one job, fail-fast.
- run: npm run format:check # fails on a missing comma
- run: npm run check # never runs
- run: npm run test # never runsGood — independent checks in parallel, formatting fixed rather than reported.
jobs:
check:
strategy:
fail-fast: false
matrix:
task: [lint, check, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "22", cache: "npm" }
- run: npm ci
- run: npm run ${{ matrix.task }}Three round trips, thirty-three minutes.
The author fixes the comma, pushes, waits eleven minutes, and only then learns about the type error.
And the first thing they were shown was whitespace, which a machine could have fixed without telling anyone.
One round trip, eleven minutes.
Every check runs regardless of the others, so one run reports everything that is wrong.
Formatting belongs in a pre-commit hook or an auto-fix job, not as a gate that hides real failures behind it.
The pipeline has to stay under a few minutes or people stop waiting for it.
Cache the install. cache: "npm" in setup-node covers
most of it.
Cache the incremental build info:
The restore-keys fallback is what makes this work: an exact
miss still restores the most recent cache for the same
dependencies, so the check is incremental rather than cold.
Check only what changed, for a large monorepo — turbo and
nx both do this, and project references from the structure
lesson are what make it possible.
Run the expensive things less often. End-to-end tests on merge rather than on every push; a nightly full run. Not everything needs to gate every commit, and pretending otherwise is how a pipeline reaches forty minutes.
Three, and each has a specific cause.
Case sensitivity. macOS and Windows ignore filename case;
Linux does not. import "./Captions.js" for a file called
captions.ts works locally and fails on a Linux runner.
That turns it into an error where you wrote it. It is on by default in recent versions and worth confirming in an older project.
Files not committed. A new file that works locally because
it exists on your disk and is missing from the repository. git status before pushing, and a .gitignore that does not exclude
something you need.
Different versions. A dependency resolved to a newer version
locally because npm install was run at a different time. npm ci fixes this, which is why it is the only install command that
belongs in a pipeline.
By default tsc output in a log is a wall of text. Annotations
put errors on the diff:
if: always() matters — without it the reporting step is
skipped precisely when there is something to report.
tsc --pretty false produces machine-readable output, and
several actions convert it into inline annotations. A reviewer
then sees the error on the line rather than in a log they have
to open.
And for anything that runs on a schedule rather than on a push, make failures reach someone. A nightly job that has been red for three weeks is worse than no job, because it teaches everyone that red is normal.
Checks that do not block anything are advisory. Making them required is one setting:
Add the trend metrics that show whether things are improving:
A number in the log, going down over time, turns an accumulating
pile of suppressions into something visible. The same works for
any counts and for the strict-mode file list from earlier in
this course.
That closes TypeScript in Practice. You arrived able to write the language and now have what a working codebase needs: strict settings adopted in an order somebody will finish, generics that preserve information, narrowing you can extend, domains modelled so illegal states cannot be written, validation at the boundary the checker cannot reach, module resolution that matches the runtime, tests covering what types do not, and a gate that runs all of it.
Next is Advanced TypeScript, which treats the type system as a programmable language. Conditional and mapped types, template literal types, inference, variance, declaration files, and the compiler API — plus the recurring question of when a clever type costs more than the bugs it prevents.
Before you move on, time your pipeline and look at what it does first. If the first thing that can fail is formatting, move it — that one change means every run reports the problem that actually matters.
branch protection on the default branch
require status checks to pass
require branches to be up to date
no direct pushesFOUR CHECKS - none subsumes another
tsc --noEmit types
eslint . bug patterns the checker cannot see
prettier --check formatting
vitest run behaviour
the BUILD does not type-check - it strips types and emits
npm ci, never npm install
ORDER
run them in PARALLEL with fail-fast: false
sequential fail-fast = one problem per round trip
formatting first hides the real failure behind whitespace
-> auto-fix formatting; do not gate on it
FAST ENOUGH THAT NOBODY BYPASSES IT
cache: "npm" in setup-node
incremental + tsBuildInfoFile + actions/cache with restore-keys
affected-only checks in a monorepo (turbo/nx + project refs)
expensive suites on merge or nightly, not every push
FAILURES THAT ONLY HAPPEN IN CI
case sensitivity Linux cares; your Mac does not
forceConsistentCasingInFileNames
uncommitted files it works locally because it is on your disk
version drift npm ci, a pinned Node, .nvmrc, engines
REPORTING
annotations on the diff, not a log to scroll
if: always() on the reporting step, or it is skipped when
there is something to report
a scheduled job that is always red teaches everyone red is fine
THE GATE
branch protection: required checks, up to date, no direct push
print trend numbers - suppressions, any counts - so a growing
pile is visible{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "node_modules/.cache/tsconfig.tsbuildinfo"
}
} - uses: actions/cache@v4
with:
path: node_modules/.cache
key: ts-${{ hashFiles('**/package-lock.json') }}-${{ github.sha }}
restore-keys: ts-${{ hashFiles('**/package-lock.json') }}-{ "forceConsistentCasingInFileNames": true } - run: npm run check
- uses: reviewdog/action-eslint@v1
if: always()
with:
reporter: github-pr-review - run: |
echo "suppressions: $(grep -rc '@ts-expect-error' src | \
awk -F: '{s+=$2} END {print s}')"