Type checking only protects a codebase if it runs on every change. The command is the one you run locally, tsc -p jsconfig.json: it exits with 0 when there are no errors and with a non-zero code when there are, which is all a CI system needs. This guide puts it in a GitHub Actions workflow, makes it fast, extends it to monorepos and a pre-commit hook, and shows how to fail only on new errors while a migration still has old ones.
The workflow
Add tsc as an npm script first, so CI, the hook and your editor’s task runner all run the same thing:
npm pkg set scripts.lint="tsc -p jsconfig.json"Then a workflow that installs from the lockfile and runs the check on every pull request and every push to main:
name: Type check
on:
pull_request:
push:
branches: [main]
concurrency:
group: typecheck-${{ github.ref }}
cancel-in-progress: true
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint -- --pretty falsenpm ciinstalls exactly whatpackage-lock.jsonsays, starting from an emptynode_modules, so CI checks against the same TypeScript and@typesversions as everyone else. Per npm’s documentation it needs an existing lockfile.cache: npmkeeps npm’s download cache between runs, keyed on the lockfile;npm cistill installs, but from the cache.concurrencycancels a run that a newer push to the same branch makes obsolete.--pretty falseprints each error on one line,src/cart.js(12,5): error TS2345: …. tsc already does that when its output is not a terminal; saying so keeps it stable.
The action versions are the current major versions on 21 October 2026. Pin TypeScript itself in package.json ("typescript": "~5.9.3"), so a new minor release, which can add checks, arrives in a pull request of its own.
Errors as annotations
actions/setup-node registers a problem matcher for tsc’s output: a pattern that turns each matching log line into an annotation on the file and line it names. Errors then appear in the pull request’s “Files changed” view next to the code, not only in the log. The one-line format above is the one it reads.
A summary on the run page
GitHub shows Markdown written to the file named in GITHUB_STEP_SUMMARY on the run’s summary page. The baseline script below writes one line there; for a simple count, a shell step is enough:
echo "### Type check: $(npm run lint --silent -- --pretty false | grep -c 'error TS') errors" >> "$GITHUB_STEP_SUMMARY"Making it faster
For most projects tsc takes seconds and needs nothing more. For large ones, --incremental stores what tsc learned in a .tsbuildinfo file and rereads only what changed; since TypeScript 4.0 it works with noEmit. Keep the file outside the source tree and cache it between runs:
- uses: actions/cache@v6
with:
path: node_modules/.cache/tsc
key: tsc-${{ hashFiles('package-lock.json', 'jsconfig.json') }}-${{ github.sha }}
restore-keys: tsc-${{ hashFiles('package-lock.json', 'jsconfig.json') }}-
- run: npm run lint -- --pretty false --incremental --tsBuildInfoFile node_modules/.cache/tsc/jsconfig.tsbuildinfoThe key includes the commit, so each run saves its own file, and restore-keys picks up the newest one from the same lockfile and config. A stale .tsbuildinfo costs time, never correctness: tsc checks what changed against it.
Monorepos
A monorepo has two reasonable shapes, both covered in the monorepo setup:
- One check for the whole repository. The root
jsconfig.jsonincludes every package’s source, and CI runs onetsc -p jsconfig.json. It is the simplest, and it checks imports across packages. - One check per package. Each package has its own
jsconfig.jsonandlintscript, and CI runs them all withnpm run lint --workspaces --if-present. It reports per package, and a package can move to a stricter level on its own.
Project references (tsc -b) are TypeScript’s way of building packages in order, but they refuse noEmit (TS6310), so a JavaScript monorepo would have to write .d.ts files to use them. Most do not need them for checking.
A pre-commit hook
A git hook runs the check before each commit, so errors are found before they reach CI. Put the hook in the repository, so everyone gets the same one:
#!/bin/sh
# Type-check before each commit. A non-zero exit stops the commit.
npm run lint --silent -- --incremental --tsBuildInfoFile node_modules/.cache/tsc/hook.tsbuildinfochmod +x .githooks/pre-commit
git config core.hooksPath .githooks
npm pkg set scripts.prepare="git config core.hooksPath .githooks"core.hooksPath (Git 2.9 and later) points git at the folder, and the prepare script sets it for everyone who runs npm install. Per git’s documentation, a pre-commit hook that exits with a non-zero status aborts the commit, and git commit --no-verify skips it, which is why CI remains the check that counts.
Failing only on new errors
During a migration the project may have hundreds of errors you are not ready to fix, and a red check on every pull request teaches people to ignore it. A baseline file records today’s errors; CI fails only when a file has more errors of a code than the baseline allows. The script below counts errors per file and code, without line numbers, so moving code around does not count as a new error:
// npx tsc -p jsconfig.json --pretty false > tsc-output.txt || true
// node check-baseline.js tsc-output.txt compare with tsc-baseline.json
// node check-baseline.js tsc-output.txt --update write tsc-baseline.json
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
const BASELINE = "tsc-baseline.json";
const [outputFile, flag] = process.argv.slice(2);
if (!outputFile) {
console.error("usage: node check-baseline.js <tsc-output.txt> [--update]");
process.exit(2);
}
/**
* Errors per file and code. Line numbers are left out, so moving code is not a new error.
* @param {string} output tsc's output with --pretty false
* @returns {Record<string, number>}
*/
function countErrors(output) {
/** @type {Record<string, number>} */
const counts = {};
for (const line of output.split(/\r?\n/)) {
const match = /^(.+?)\(\d+,\d+\): error (TS\d+):/.exec(line);
if (!match) continue;
const key = `${match[1]} ${match[2]}`;
counts[key] = (counts[key] ?? 0) + 1;
}
return counts;
}
const current = countErrors(readFileSync(outputFile, "utf8"));
if (flag === "--update") {
writeFileSync(BASELINE, `${JSON.stringify(current, null, 2)}\n`);
console.log(`Wrote ${BASELINE}: ${Object.keys(current).length} entries.`);
process.exit(0);
}
/** @type {Record<string, number>} */
const baseline = existsSync(BASELINE) ? JSON.parse(readFileSync(BASELINE, "utf8")) : {};
const worse = Object.entries(current).filter(([key, count]) => count > (baseline[key] ?? 0));
const better = Object.entries(baseline).filter(([key, count]) => (current[key] ?? 0) < count);
for (const [key, count] of worse) console.error(`New: ${key} (${baseline[key] ?? 0} in the baseline, ${count} now)`);
if (better.length) console.log(`Fewer errors than the baseline in ${better.length} places: run with --update to lock that in.`);
const summary = process.env.GITHUB_STEP_SUMMARY;
if (summary) {
const total = Object.values(current).reduce((sum, count) => sum + count, 0);
appendFileSync(summary, `### Type check\n\n${total} errors, ${worse.length} new against the baseline.\n`);
}
process.exit(worse.length > 0 ? 1 : 0);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["**/*.js"]
} Write the baseline once and commit it, then compare on every run:
npx tsc -p jsconfig.json --pretty false > tsc-output.txt || true
node scripts/check-baseline.js tsc-output.txt --update
git add tsc-baseline.json - run: npx tsc -p jsconfig.json --pretty false > tsc-output.txt || true
- run: node scripts/check-baseline.js tsc-output.txtThe || true keeps tsc’s own non-zero exit code from ending the step: the script decides. When a pull request fixes errors, the script says so, and running it with --update in that pull request locks the improvement in. Once the baseline is empty, delete it and go back to the plain npm run lint step.
Every example on this page was checked with TypeScript 5.9; the workflow files are shown as written, not run here.
Questions
Does tsc need --noEmit in CI?
Not with a jsconfig.json, which sets noEmit: true by default, and not with a config that sets it. Running tsc -p jsconfig.json checks and writes nothing; the exit code is 0 when there are no errors and non-zero when there are.
Can a pre-commit hook check only the staged files?
Not with tsc's project mode: tsc -p checks the whole program, because a change in one file can break another. Keep the hook fast with --incremental instead, and leave the complete check to CI.
How do errors show up on the pull request?
actions/setup-node registers a problem matcher for tsc's output, so each error in the plain one-line format becomes an annotation on the line it names. --pretty false keeps the output in that format.
Sources
- actions/setup-node (caching, problem matchers) · GitHub
- Problem matchers · GitHub
- Workflow commands: adding a job summary · GitHub Docs
- TypeScript 4.0: --incremental with --noEmit · TypeScript handbook
- npm ci · npm Docs
- githooks · Git documentation
Last reviewed 21 October 2026.