Most of tsconfig.json you set once and never look at again. A dozen or so options
genuinely change what compiles, and those are worth understanding rather than copying.
This lesson works from the two real configurations in the pizza app, which are a useful pair because they disagree — and each one forbids something the other depends on.
strict, taken apart
"strict": true is the single most consequential line, and it is a switch for a
family:
| Flag | What it stops |
|---|---|
strictNullChecks | null/undefined being silently allowed in every type |
noImplicitAny | an unannotated parameter quietly becoming any |
strictFunctionTypes | unsound callback parameter substitution |
strictBindCallApply | call/apply with the wrong arguments |
strictPropertyInitialization | a declared class field never assigned |
noImplicitThis | this being any in a loose function |
useUnknownInCatchVariables | catch (e) giving you any |
alwaysStrict | output without "use strict" |
Of those, strictNullChecks is most of the value. Without it a
string silently includes null, and the runtime errors you adopted
TypeScript to prevent are all still possible.
On a new project, turn the lot on and never think about it. On an existing one, enable them
individually — that is why they exist as separate flags — and do noImplicitAny before
strictNullChecks, because the second produces far more errors and they are easier to
judge once the first is clean.
target and lib
Two settings people conflate. target decides the JavaScript syntax emitted;
lib decides which APIs the checker believes exist.
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],target is set by what has to run your code. Modern browsers and Node 18+ handle
ES2022 comfortably; a lower target makes the output larger and, as
lesson 12 showed, breaks instanceof on
subclassed built-ins.
lib defaults from target, and you set it explicitly when the two differ
— a polyfilled environment, or code that must not touch the DOM. That is the interesting half of
the pizza app's split:
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,That is tsconfig.node.json, covering vite.config.ts. No
DOM, because a build script has no document. With one shared config,
document would autocomplete inside the build script and process.env inside
a component — each of them a crash waiting to happen.
types controls which global @types packages are loaded. Omit it and
every package in node_modules/@types is included globally, which is usually
harmless and occasionally the reason two libraries' globals collide.
module and moduleResolution
Covered in lesson 17. The short version: a frontend
wants "module": "esnext" with "moduleResolution": "bundler", and a Node
service wants "nodenext" for both. Mixing them produces import errors that look like
missing files.
noEmit and friends
"noEmit": true,
"jsx": "react-jsx",noEmit makes tsc a checker only, which is the right division of labour
when a bundler is compiling —
lesson 2 covers why. If you are emitting, the
ones you care about are outDir, declaration (emit .d.ts,
required if you are publishing a library) and sourceMap.
jsx: "react-jsx" is the modern transform — no import React needed in
every file.
The linting flags
These are not about correctness so much as hygiene, and the React app turns on four:
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": truenoUnusedLocals and noUnusedParameters catch leftovers. Prefix a
parameter with _ to say it is deliberately unused.
noFallthroughCasesInSwitch catches a case with no
break — the companion to the never exhaustiveness check from
lesson 10. One catches a case you forgot to write,
the other a case you forgot to end.
erasableSyntaxOnly is the interesting one, and it is the reason this app has no
enum and no parameter properties. It rejects every construct that has to
generate code rather than merely be deleted:
enum- parameter properties —
constructor(private api: ApiService) namespacewith runtime members- decorators
Turning it on guarantees your source can be run by a type-stripper — Node's built-in support, esbuild, or any other tool that deletes annotations without understanding them. It is a bet on where the tooling is going, and a reasonable one.
Where the two configs disagree
The Angular half sets the opposite:
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,experimentalDecorators is exactly what erasableSyntaxOnly forbids.
Angular's components, services, pipes and directives are all declared with decorators, so the
framework cannot work without it — see the next
lesson.
Neither app is misconfigured. One is a Vite project betting on type-stripping; the other is an Angular project whose compiler reads whole programs and generates code anyway. The configuration follows the toolchain, and this is worth internalising: a tsconfig is a description of your build, not a statement of taste.
Three of Angular's flags are worth stealing regardless. noImplicitOverride requires
the override keyword when a subclass replaces a member, so a renamed base method does
not silently orphan its override. noImplicitReturns catches a function with a return
on some paths and not others. noPropertyAccessFromIndexSignature forces
obj['key'] rather than obj.key for index-signature properties, which
makes the "this key might not exist" cases visible.
Two more worth turning on
noUncheckedIndexedAccess makes every indexed read include
undefined — the hole from
lesson 5, where
sizes[0] is typed as present on an empty array. Genuinely noisy, and genuinely correct.
exactOptionalPropertyTypes makes phone?: string mean "may be absent"
rather than "may be absent or explicitly undefined", which is the distinction from
lesson 6. Useful when you serialise objects and the
difference reaches the wire.
Both are off by default because both break existing code. Both are worth it on something new.
skipLibCheck
Set in every config in this app, and in most projects:
"skipLibCheck": true,It stops TypeScript typechecking the .d.ts files in
node_modules. That sounds reckless and is standard practice — your dependencies'
declarations are not your problem, two libraries with conflicting global types would otherwise fail
your build, and checking them is slow.
Your own code is still fully checked. The declarations are still used; they are just not verified against each other.
Project references
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}A root config that compiles nothing and points at the real ones, built together with
tsc -b. This is the standard Vite layout, and the mechanism scales to a monorepo where
each package is a referenced project and only the changed ones rebuild.
extends, and sharing configuration
A config can inherit from another, which is how the Angular app avoids repeating itself:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},The base holds the strictness settings; the app config adds an output directory and narrows
types. Merging is shallow and per-option — an object like paths is
replaced wholesale, not merged — and relative paths in the base resolve relative to the base
file, which is the detail that catches people out when extending a shared package.
You can also extend a published config, which is how @tsconfig/node22 and
similar work:
"extends": "@tsconfig/node22/tsconfig.json",When the type checker gets slow
On a large project tsc eventually takes long enough to notice. Four things to check,
roughly in order of payoff.
skipLibCheck, as above. If it is off, turn it on — this is usually
the single biggest win and it costs nothing you wanted.
incremental writes a .tsbuildinfo file so a rebuild only
does the changed work. composite implies it and is required for project references. Do
add the build-info file to .gitignore — and be aware that a stale one can produce
errors that do not match your source, which is worth knowing before you spend an hour on a
phantom.
Check what is in the program. tsc --listFiles prints every file
being checked, and the answer is sometimes a surprise — a wide include, a barrel that
pulls in a directory, or a missing exclude.
Look for expensive types. tsc --diagnostics reports where the time
went, and a large "check time" against a small program usually means a recursive conditional type —
see lesson 15.
A starting point
For a new frontend, the React app's configuration is a good default as it stands. For a new Node
service, the changes are "module": "nodenext", "moduleResolution": "nodenext",
"noEmit": false, an outDir, and lib without
DOM.
In both cases: strict on from the first commit. It is the one setting that is
painful to adopt later and free to adopt now.
One last piece of advice on all of this. Do not copy a tsconfig from a blog post, including this
one, without reading what each line does — a config is the most quietly consequential file in a
TypeScript project, and a setting you did not intend can disable a check you were relying on for
years without ever producing a message. tsc --showConfig prints the fully resolved
configuration, including everything inherited through extends, which is the quickest
way to find out what you are actually compiling with.
The corollary is that a config file you cannot explain is a liability. Every line in it was added by somebody for a reason, and the reasons are rarely written down.
And if you inherit a project with strict off, resist the urge to fix it in one
commit. The order that works is the one from earlier — noImplicitAny, then
strictNullChecks, then the rest — with each step merged separately so the diff stays
reviewable and a regression can be traced to a flag rather than to a fortnight.
Two small conveniences worth knowing about while you are in there.
allowJs plus checkJs are the migration pair from
lesson 2. And resolveJsonModule lets you
import config from './config.json' with the shape inferred from the file itself, which
is one of the nicer small things the compiler does.
Where the file goes
One structural point that trips people up on a first project. tsc looks for a
tsconfig.json in the current directory and then upward, and the directory
containing it is the project root — every relative path inside is resolved from there, not from
where you ran the command.
So in a monorepo, each package gets its own config, and a root config either references them all
or holds only the settings they extend. Putting one config at the root and pointing
include at every package works until two of them need different
lib settings, which is usually about a fortnight.
Next
Decorators — what @Component and
@Injectable are actually doing, and why experimentalDecorators is still
called experimental.