tsc (the TypeScript Compiler) — Middle Level¶
Table of Contents¶
- Prerequisites
- Why & When
- Watch Mode in Depth
--noEmitand the Type-Check / Emit Split- Reading tsc Error Messages
- Error Codes (TSxxxx) Catalog
- Exit Codes for CI
- Selecting a Config:
-p/--project - Common Flags You Will Actually Use
--prettyand Output Formatting- Inspecting What tsc Sees:
--listFilesand--showConfig - Type Checking as a CI Gate
- Coding Patterns
- Error Handling Walkthroughs
- Best Practices
- Edge Cases & Pitfalls
- Common Mistakes
- Tricky Points
- Test
- Cheat Sheet
- Middle Checklist
- Summary
- Further Reading
Prerequisites¶
- Comfortable compiling a project with bare
tscand atsconfig.json. - Knows what
outDir,target,module, andstrictdo. - Has run
tsc --noEmitat least once. - Builds real projects (Node CLI, React app, or a library).
- Understands npm scripts and how CI pipelines run shell commands.
Why & When¶
Focus: "Why?" and "When?"
At the junior level you learned what tsc does. At the middle level the question becomes why you choose a particular mode and when each one matters in a real workflow.
The central idea: tsc has two responsibilities — type checking and emitting — and in production you usually want to control them independently.
- A bundler-first project (Vite, Next.js, esbuild) lets the bundler emit JavaScript, because it is 10–100× faster at transpiling.
tscis reduced to a pure type checker via--noEmit. - A plain Node library often uses
tscfor both jobs because it also needs.d.tsfiles, which bundlers historically do not produce well. - CI almost always runs
tsc --noEmitas a gate, regardless of how the actual build is done, because the bundler does not type-check.
That last point surprises people: esbuild, swc, and Babel strip types but never check them. If tsc is not in your pipeline, nothing is checking your types. Your "build" can succeed while your code is full of type errors.
Watch Mode in Depth¶
Watch mode (--watch / -w) keeps tsc resident in memory and recompiles incrementally when files change. The key advantage is that it reuses the previous program state: the scanner, parser, and type checker do not start from zero on each change — only the affected files are re-checked.
Sample session:
[10:00:00 AM] Starting compilation in watch mode...
[10:00:02 AM] Found 0 errors. Watching for file changes.
[10:00:18 AM] File change detected. Starting incremental compilation...
[10:00:18 AM] Found 1 error. Watching for file changes.
src/user.ts:12:3 - error TS2322: Type 'number' is not assignable to type 'string'.
Tuning the watcher¶
Large repos on certain file systems benefit from configuring how tsc watches.
// tsconfig.json
{
"watchOptions": {
"watchFile": "useFsEvents",
"watchDirectory": "useFsEvents",
"fallbackPolling": "dynamicPriority",
"synchronousWatchDirectory": true,
"excludeDirectories": ["**/node_modules", "dist"]
}
}
| Option | Purpose |
|---|---|
watchFile | Strategy for watching individual files (useFsEvents, priorityPollingInterval, etc.) |
watchDirectory | Strategy for watching directories |
fallbackPolling | What to do when native FS events are unavailable |
excludeDirectories | Skip watching huge folders like node_modules |
Watch + noEmit¶
You can combine watch with type-check-only mode for a fast local "is my code correct?" loop without ever writing files:
This is the ideal companion to vite dev (which handles emit/HMR) so you get continuous type feedback in a second terminal.
--noEmit and the Type-Check / Emit Split¶
--noEmit runs the full pipeline through the type checker but stops before the emitter. No .js, .d.ts, or .map files are written.
Related emit-controlling flags worth knowing:
| Flag | Effect |
|---|---|
--noEmit | Type-check only; write nothing |
--noEmitOnError | Emit normally, but write nothing if any error occurred |
--emitDeclarationOnly | Emit only .d.ts files, no .js |
--declaration | Emit .d.ts alongside .js |
--noCheck | (TS 5.6+) Emit without full type checking — speed over safety |
When to use which¶
# CI type gate — never need output
tsc --noEmit
# Production build that must not ship broken JS
tsc --noEmitOnError
# Library: ship types only, let a bundler do JS
tsc --emitDeclarationOnly --declaration
The mental model: --noEmit answers "is it correct?"; a real build answers "give me the output." Keep them as separate commands so a type failure can block a merge independently of how you build.
Reading tsc Error Messages¶
A tsc diagnostic has a consistent anatomy. Learning to read it quickly is a core middle-level skill.
src/order.ts:14:22 - error TS2345: Argument of type 'string' is not
assignable to parameter of type 'number'.
14 processPayment(order.id);
~~~~~~~~
Breaking it down:
| Part | Value | Meaning |
|---|---|---|
| File | src/order.ts | Where the error is |
| Position | 14:22 | Line 14, column 22 |
| Severity | error | error or message/suggestion |
| Code | TS2345 | The stable error code (searchable) |
| Message | Argument of type ... | The human-readable explanation |
| Caret line | ~~~~~~~~ | Points at the exact offending span |
Following the "chain" in complex errors¶
For object and generic mismatches, tsc prints a nested explanation that drills from the outer type to the precise incompatible leaf:
src/api.ts:30:3 - error TS2322: Type '{ id: number; name: string; }'
is not assignable to type 'User'.
Types of property 'id' are incompatible.
Type 'number' is not assignable to type 'string'.
Read it top to bottom: the outer types don't match → because property id differs → because number ≠ string. The deepest line is usually the real fix point.
Related-information arrows¶
Some errors include a second location with extra context:
src/handler.ts:8:5 - error TS2554: Expected 2 arguments, but got 1.
8 send(payload);
~~~~~~~~~~~~~
src/transport.ts:3:23
3 export function send(payload: Buffer, retries: number): void {
~~~~~~~~~~~~~~~
An argument for 'retries' was not provided.
The indented block points to the declaration so you can see what the function actually expects.
Error Codes (TSxxxx) Catalog¶
Every diagnostic carries a stable TSxxxx code. These are great to memorize for the most common ones and to search online for the rest.
| Code | Meaning | Typical cause |
|---|---|---|
TS2304 | Cannot find name 'X' | Typo or missing import |
TS2307 | Cannot find module 'X' | Missing dependency or wrong path |
TS2322 | Type 'A' is not assignable to type 'B' | Wrong value assigned |
TS2345 | Argument of type 'A' not assignable to 'B' | Wrong function argument |
TS2339 | Property 'X' does not exist on type 'Y' | Accessing a missing field |
TS2531 | Object is possibly 'null' | Missing null check (strictNullChecks) |
TS2532 | Object is possibly 'undefined' | Missing undefined check |
TS2554 | Expected N arguments, but got M | Wrong arity |
TS2769 | No overload matches this call | Wrong overload usage |
TS7006 | Parameter 'x' implicitly has an 'any' type | Missing annotation under noImplicitAny |
TS6133 | 'x' is declared but its value is never read | Unused variable (noUnusedLocals) |
TS18003 | No inputs were found in config file | Empty include/files |
TS5023 | Unknown compiler option 'X' | Typo in tsconfig |
TS5083 | Cannot read file 'tsconfig.json' | Wrong -p path |
# Searching the code is the fastest way to understand an unfamiliar error
# e.g., "TypeScript TS2769 no overload matches this call"
Exit Codes for CI¶
tsc communicates success/failure to scripts via the process exit code, not the printed text.
| Exit code | Meaning |
|---|---|
0 | Success — no errors |
1 | Generic failure (e.g., crash, internal error in some cases) |
2 | Type errors / diagnostics were reported |
3 | Project reference config error / invalid --build graph |
In a CI step, a non-zero exit code automatically fails the job. Never mask it:
# WRONG — always "passes", defeating the gate
tsc --noEmit || true
# RIGHT — let the non-zero code fail the job
tsc --noEmit
Selecting a Config: -p / --project¶
-p (or --project) points tsc at a specific config file or a directory containing one. This is how you maintain multiple configs (app vs. tests vs. build).
tsc -p tsconfig.json # explicit
tsc -p tsconfig.build.json # a separate build config
tsc --project ./config # directory containing tsconfig.json
A common layout:
// tsconfig.json — editor/dev (loose, includes tests)
{
"compilerOptions": { "noEmit": true, "strict": true },
"include": ["src", "tests"]
}
// tsconfig.build.json — production emit (no tests)
{
"extends": "./tsconfig.json",
"compilerOptions": { "noEmit": false, "outDir": "dist" },
"include": ["src"]
}
You cannot pass file arguments together with
-p.tsc src/index.ts -p tsconfig.jsonis an error.
Common Flags You Will Actually Use¶
# Output location and structure
tsc --outDir dist --rootDir src
# JS language level and module format
tsc --target es2022 --module nodenext
# Safety
tsc --strict --noUncheckedIndexedAccess
# Library output
tsc --declaration --declarationMap --sourceMap
# Refuse to emit on error
tsc --noEmitOnError
# Skip checking node_modules .d.ts (faster)
tsc --skipLibCheck
| Flag | What it does | When to use |
|---|---|---|
--outDir | Where to put .js | Always (keep dist separate) |
--rootDir | Base for input structure | When outDir mirrors a subfolder |
--target | JS version to emit | Match your runtime |
--module | Module system | nodenext for Node, esnext for bundlers |
--strict | All strict checks | Always |
--declaration | Emit .d.ts | Libraries |
--sourceMap | Emit .map | Debugging |
--skipLibCheck | Don't check lib .d.ts | Speed |
--incremental | Cache to .tsbuildinfo | Faster rebuilds |
Flags on the command line override the same setting in
tsconfig.jsonfor that run. This is handy for one-off overrides without editing the config.
--pretty and Output Formatting¶
tsc formats errors with colors and the caret/underline display by default when writing to a TTY. You can force it on or off.
# Force the human-friendly colored/underlined output
tsc --pretty
# Force plain output (no colors, no caret line) — better for log files / CI parsing
tsc --pretty false
Plain output looks like:
src/order.ts(14,22): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
The file(line,col): error TSxxxx: format is machine-friendly and is what many CI annotators and editors parse.
Inspecting What tsc Sees: --listFiles and --showConfig¶
When the compiler behaves unexpectedly, these two flags reveal what tsc actually thinks it should do.
--showConfig¶
Prints the fully resolved config — after extends chains are merged and defaults are applied. Invaluable for debugging "why is this option set?".
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"strict": true,
"outDir": "./dist"
},
"files": [
"./src/index.ts",
"./src/user.ts"
]
}
--listFiles and --explainFiles¶
--listFiles prints every file included in the compilation — including all the .d.ts files pulled from node_modules and lib. If your build is slow or includes too much, this shows why.
tsc --noEmit --listFiles
# /project/node_modules/typescript/lib/lib.es2022.d.ts
# /project/node_modules/@types/node/index.d.ts
# /project/src/index.ts
# ...
--explainFiles (TS 4.2+) goes further: for each file it explains why it was included.
tsc --noEmit --explainFiles
# src/index.ts
# Matched by include pattern 'src' in 'tsconfig.json'
# node_modules/@types/node/index.d.ts
# Entry point for implicit type library 'node'
Type Checking as a CI Gate¶
The canonical pipeline: bundler builds, tsc checks. Here is a complete GitHub Actions example.
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Type check
run: npx tsc --noEmit --pretty false
Notes: - npm ci installs the locked typescript version, so CI matches local. - --noEmit because CI does not need output; it only needs the verdict. - --pretty false keeps the log clean and machine-parseable. - A type error makes tsc exit 2, which fails the job automatically.
For monorepos, swap in build mode:
Coding Patterns¶
Pattern 1: Two configs, two scripts¶
// package.json
{
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"build": "tsc -p tsconfig.build.json",
"dev": "tsc --noEmit --watch"
}
}
Pattern 2: Pre-commit type check¶
Pattern 3: Parallel check + bundle in build¶
{
"scripts": {
"build": "npm-run-all --parallel build:types build:js",
"build:types": "tsc --emitDeclarationOnly --declaration",
"build:js": "esbuild src/index.ts --bundle --outfile=dist/index.js"
}
}
Error Handling Walkthroughs¶
Walkthrough 1: TS2532 "Object is possibly 'undefined'"¶
Fix: narrow before use.
Walkthrough 2: TS2307 "Cannot find module"¶
Fix: install the package and its types.
Walkthrough 3: TS7006 implicit any¶
Fix: annotate, or import the framework types.
import type { Request, Response } from "express";
function handler(req: Request, res: Response) { /* ... */ }
Best Practices¶
- Run
tsc --noEmitin CI on every PR. It is the only real type gate. - Use
--pretty falsein CI logs for clean, parseable output. - Keep a separate
tsconfig.build.jsonso dev configs (with tests) don't leak into production output. - Pin and
npm cithe compiler so CI and local agree. - Prefer flags in
tsconfig.json; reserve CLI flags for overrides. - Use
--showConfigwhenever an option "isn't taking effect."
Edge Cases & Pitfalls¶
Pitfall 1: Bundler builds but never type-checks¶
Fix: add tsc --noEmit as a separate, mandatory step.
Pitfall 2: || true hides failures¶
Fix: remove the fallback; let the exit code propagate.
Pitfall 3: Wrong config silently used¶
Fix: use bare tsc or tsc -p.
Pitfall 4: skipLibCheck masks a real dependency type bug¶
skipLibCheck: true speeds builds but can hide an incompatibility between two @types packages. Occasionally run without it to verify.
Common Mistakes¶
Mistake 1: Thinking the bundler checks types¶
# esbuild/swc/babel only STRIP types; they never check them
esbuild src/index.ts --outfile=out.js # no type errors ever reported
Mistake 2: Forgetting --noEmit leaves stray .js in CI¶
tsc # writes files into the runner; usually pointless in a check job
tsc --noEmit # correct for a pure check
Mistake 3: Mixing -p and file arguments¶
Tricky Points¶
Tricky Point 1: CLI flags override config per-run¶
The override is not persisted — the next bare tsc goes back to es5.
Tricky Point 2: --pretty false changes the message format, not the content¶
The error code and text are identical; only colors and the caret/underline display differ. CI parsers prefer the non-pretty file(line,col): form.
Tricky Point 3: Exit code 2 vs 1¶
A reported type error is exit 2. Exit 1 usually means something else went wrong (a crash, an invalid invocation). Distinguish them when debugging flaky CI.
Test¶
Multiple Choice¶
1. Which command is the correct CI type gate?
- A)
vite build - B)
esbuild src --bundle - C)
tsc --noEmit - D)
tsc --noEmit || true
Answer
**C)** — only `tsc` checks types, and `--noEmit` avoids writing files. Option D masks failures.True or False¶
2. esbuild reports TypeScript type errors during build.
Answer
**False** — esbuild (and swc, Babel) strip types but never type-check. You need `tsc` for that.What's the Output?¶
3. What exit code follows a type error?
Answer
`2` — type diagnostics were reported.4. Which flag shows every file tsc included, with reasons?
Answer
`--explainFiles` (or `--listFiles` for just the list).5. What does --showConfig print?
Answer
The fully resolved config after `extends` merging and defaults — useful to debug surprising option values.Cheat Sheet¶
| Goal | Command |
|---|---|
| Type-check only | tsc --noEmit |
| Watch + check only | tsc --noEmit --watch |
| Use specific config | tsc -p tsconfig.build.json |
| Clean CI output | tsc --noEmit --pretty false |
| Refuse emit on error | tsc --noEmitOnError |
| Show resolved config | tsc --showConfig |
| List included files | tsc --listFiles |
| Explain file inclusion | tsc --explainFiles |
| Declarations only | tsc --emitDeclarationOnly --declaration |
| Check exit code | tsc --noEmit; echo $? |
Middle Checklist¶
- CI runs
tsc --noEmitas a required check. - No
|| truemasking the compiler's exit code. - Separate dev vs. build configs where needed.
- Bundler-first projects keep
tscas the dedicated type checker. - You can read a nested
TS2322error to its root cause. - You know
2= type errors,0= clean.
Summary¶
- The middle-level mindset: decouple type checking from emit and control each on purpose.
- Bundlers do not type-check —
tsc --noEmitis the real gate. - Watch mode reuses program state for fast incremental feedback.
- Learn to read nested error chains and the common
TSxxxxcodes. - Exit codes (
0/2) are what CI reads; never mask them. --showConfig,--listFiles,--explainFiles, and--pretty falseare your debugging/CI toolkit.
Next step: Senior-level build mode (--build), project references at scale, incremental builds, and build-performance diagnostics.
Further Reading¶
- Official docs: Compiler Options
- Official docs: Configuring Watch
- Reference: TSConfig Reference
- GitHub: TypeScript diagnostic messages