The Config File Nobody Reads Until It Breaks
Most tsconfig.json files in the wild were copy-pasted from a starter template once, in 2021, and never opened again. That's fine right up until someone asks why strict mode caught a null reference in one file but not the one next to it, or why bumping TypeScript from 5.3 to 5.4 introduced errors nobody wrote code to trigger. The compiler options aren't boilerplate. Each one changes what the checker is actually allowed to catch, and a few of them โ strict especially โ are a bundle of eight separate flags wearing a trenchcoat, not one setting.
- You inherited a codebase with
strict: falseand every attempt to turn it on produces 400+ errors on the first try - You're not sure whether
targetorlibis the setting that controls which array methods type-check - Your monorepo's
tsctakes four minutes for a one-line change because nothing is scoped into separate projects - Someone bumped the TypeScript version in a routine dependency update and CI failed with errors unrelated to anything touched in the PR
None of this is exotic โ it's the difference between a tsconfig that happens to work and one someone actually understands.
What strict: true Actually Turns On
It expands to eight individually-overridable flags, and TypeScript's own docs are upfront that future releases can add more under the same name:
{
"compilerOptions": {
"strict": true
// equivalent to setting all of these to true individually:
// "noImplicitAny": true,
// "strictNullChecks": true,
// "strictFunctionTypes": true,
// "strictBindCallApply": true,
// "strictPropertyInitialization": true,
// "noImplicitThis": true,
// "alwaysStrict": true,
// "useUnknownInCatchVariables": true
}
}On a codebase that's been running loose, two of those eight will generate the vast majority of new errors. strictNullChecks is the big one โ it makes every null and undefined an explicit part of the type instead of something quietly assignable to anything.
// strictNullChecks off โ compiles, and crashes at runtime the day 'profile' is missing
function getFirstName(user: User) {
return user.profile.name.split(' ')[0];
}
// strictNullChecks on โ TypeScript makes you answer for every optional link in the chain
function getFirstName(user: User) {
return user.profile?.name?.split(' ')[0] ?? 'Unknown';
}noImplicitAny is the second big one, mostly because it surfaces just how much of an older codebase was never actually typed โ it just looked typed because nobody turned the flag on that would have said otherwise.
// noImplicitAny off โ 'items' is silently 'any', and so is everything derived from it
function total(items) {
return items.reduce((sum, i) => sum + i.price, 0);
}
// noImplicitAny on โ forces a real type or a deliberate, visible 'any'
function total(items: { price: number }[]) {
return items.reduce((sum, i) => sum + i.price, 0);
}strictPropertyInitializationdeserves a mention too, mostly because it's the one that ambushes people using dependency-injection frameworks โ a class field populated by a decorator or a framework lifecycle hook looks "uninitialized" to the compiler, which has no idea the framework will set it later.
target vs lib
These get confused constantly because they sound like they're both about "how modern can my code be," and they're not. target controls the JavaScript syntax tsc emits โ whether async/await gets downleveled, whether class fields compile to something older engines understand. lib controls which ambient APIs the checker believes exist while type-checking, completely independent of what syntax gets written out.
flowchart LR
T["target: ES2017"] --> ST["Controls emitted syntax โ<br/>downleveling, class field compilation"]
L["lib: ES2017, DOM, ESNext.Array"] --> SL["Controls which global APIs<br/>type-check as available"]
SL -.->|"no runtime guarantee"| RT["Runtime still has to actually<br/>implement it โ lib doesn't polyfill"]
style ST fill:#0078D4,color:#fff,stroke:#005a9e
style SL fill:#0078D4,color:#fff,stroke:#005a9e
style RT fill:#dc2626,color:#fff,stroke:#b91c1c
{
"compilerOptions": {
"target": "ES2017",
"lib": ["ES2017", "DOM", "ESNext.Array"]
}
}const last = [1, 2, 3].at(-1); // โ
typechecks โ ESNext.Array is in 'lib'
// But 'lib' is a type-checking hint, not a polyfill. If the actual runtime โ an older
// Node LTS, an old Safari โ doesn't implement Array.prototype.at(), this throws
// "items.at is not a function" at runtime, and tsc never warned you, because you
// told it, via 'lib', to assume the method exists.Project References for Monorepos
composite: true is what turns a package into something tsc -bcan reason about as an independent unit โ it forces the project's public surface to be fully typed rather than leaning on cross-project inference, which is exactly the constraint that lets the build tool know "nothing this package depends on changed, skip it" without re-checking the whole graph.
// packages/shared/tsconfig.json
{ "compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" } }
// packages/api/tsconfig.json
{
"compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" },
"references": [{ "path": "../shared" }]
}
// root tsconfig.json โ a "solution" file, no files of its own
{
"files": [],
"references": [{ "path": "packages/shared" }, { "path": "packages/api" }]
}npx tsc -b # rebuilds only the projects whose inputs actually changed
npx tsc -b --clean # removes build outputs and .tsbuildinfo for a fresh runIncremental Builds
incremental: true (implied by composite) writes a .tsbuildinfo file recording file versions and the dependency graph from the last compile. The next run diffs against it and only re-checks and re-emits what actually changed, instead of starting cold every time.
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./dist/.tsbuildinfo"
}
}Adopting Strict Mode on an Existing Codebase
Flipping strict: true on a large codebase in one commit produces an error count nobody has the appetite to work through, so it gets reverted, and the codebase stays loose for another year. The flags one at a time approach actually ships: start with noImplicitAny, because fixing it is mostly mechanical โ add a real type or an explicit, visible any and move on. Save strictNullChecksfor last; it's the one that touches the most call sites and needs actual judgment about what can and can't be null at each point, not just a type annotation.
For lines you're deliberately deferring mid-migration, reach for // @ts-expect-error over // @ts-ignore. The difference matters more than it looks: @ts-expect-erroritself errors if the line it's suppressing stops producing an error, so when someone eventually fixes the real problem, a stale suppression comment gets flagged instead of quietly rotting in the codebase forever.
// @ts-expect-error โ legacy field, will be null-checked properly in TICKET-4213
const name = user.profile.name.toUpperCase();And for the plain .js files that inevitably still exist somewhere in an older codebase, checkJs alongside allowJs brings them under the same checker using JSDoc annotations, which buys real type-checking before anyone commits to a full file-by-file rewrite to .ts.
Pitfall: strict Is a Moving Target
TypeScript's own reference for the strictflag says outright that future versions may introduce additional checks under it. That's not a footnote โ it means a routine npm update that bumps the TypeScript minor version can introduce compile errors in code nobody touched, with zero tsconfig changes on your end. If a project leans on strict: truerather than the individual flags, pin the TypeScript version deliberately and actually read the release notes' breaking-changes section before bumping โ this is the specific scenario that section exists for.
Key Takeaways
- strict is eight flags bundled together, and enabling them one at a time โ noImplicitAny first, strictNullChecks last โ is far more tractable on a legacy codebase than flipping the bundle at once.
- target and lib are independent: one controls emitted syntax, the other controls which APIs type-check as available. Assuming a lib method the target runtime doesn't have fails silently at compile time and loudly at runtime.
- Project references exist so a monorepo doesn't re-check packages that haven't changed โ composite is what makes that dependency graph legible to tsc -b.
- @ts-expect-error self-destructs when the underlying error is fixed; @ts-ignore doesn't. During a migration, that difference is the whole point.

