Strict Mode & TSConfig
The flags that turn TypeScript from suggestions into enforcement.
Strict Mode & TSConfig
Out of the box, TypeScript is permissive — too permissive. The only setting that matters is strict, and even with it on, there are extra opt-ins that catch real bugs. This lesson walks through the flags that actually pay off, ordered by ROI.
strict: true — the foundation
"strict": true is shorthand for the entire strict family:
strictNullChecks—null/undefinedare their own types, not values of every type.noImplicitAny— function parameters, variable initialisers, and return types must have a type.strictFunctionTypes— function parameter types are checked contravariantly (mathematically correct).strictBindCallApply—Function.prototype.bind/call/applyare typed.strictPropertyInitialization— class fields must be assigned in the constructor.noImplicitThis—thismust be typed when not implicit.useUnknownInCatchVariables—catch (e)typeseasunknown, notany.alwaysStrict— emit"use strict"and parse in strict mode.
If you take exactly one thing from this lesson: always start a project with strict: true. Adding strict mode to a mature codebase is brutal; starting strict is free.
The two flags strict doesn't include
noUncheckedIndexedAccess
The single most useful non-strict flag. Without it:
const arr = ["a", "b"];
const c: string = arr[5]; // typed as string — but it's actually undefined!
c.length; // crashes at runtime
With it:
const c: string | undefined = arr[5]; // forced to handle the maybe-missing case
Same applies to Record types and untyped object keys. Catches a category of bug TypeScript would otherwise wave through.
exactOptionalPropertyTypes
Distinguishes "absent" from "present-but-undefined":
interface User { email?: string }
// Without exactOptionalPropertyTypes (the default):
const u: User = { email: undefined }; // ✓ legal — undefined is "missing enough"
// With exactOptionalPropertyTypes:
const u: User = { email: undefined }; // ❌ undefined not assignable to string
const u: User = {}; // ✓ truly absent
delete u.email; // ✓ legal way to clear
Useful when you're modelling JSON or a precise wire format. Painful when interacting with libraries that assume undefined clears an optional. Most teams skip this one until it earns its way in.
The "I caught a bug" flags
These don't have category-defining names, but they each prevent a specific common mistake:
noFallthroughCasesInSwitch
switch (k) {
case 1: doA(); // ❌ missing break
case 2: doB(); break;
}
If you forgot the break and didn't intend fall-through, this catches it. Fall-through that's intentional needs a // falls through comment.
noImplicitReturns
function pick(b: boolean): number {
if (b) return 1;
// ❌ Not all code paths return a value
}
Catches missing return statements in branches.
noUnusedLocals / noUnusedParameters
Errors on dead code. Forces you to delete or _-prefix. (function f(_x: number) is the convention for an intentionally-unused parameter.)
noImplicitOverride
class Base { greet() { /* … */ } }
class Sub extends Base {
override greet() { /* … */ } // ✓ explicit
greet() { /* … */ } // ❌ requires `override` modifier
}
Prevents the bug where a parent class adds a method that silently shadows a similarly-named child method.
Module / output flags
isolatedModules
Required by single-file transpilers (Vite, esbuild, swc — anything that compiles one file at a time). Disallows features that need full-program type info, like const enum. Modern projects should turn this on.
verbatimModuleSyntax
Forces every type-only import to be marked as such (import type X from …). Removes ambiguity for build tools — no more guessing whether to keep an import.
skipLibCheck
Skip type-checking your dependencies' .d.ts files. Saves a lot of build time at the cost of catching bugs in libraries you depend on (rare, and not your problem to fix). Effectively-mandatory for any non-trivial project.
Build performance
incremental and tsBuildInfoFile
Caches type-check info across runs. Massive speedup for large projects.
composite and project references
Splits a monorepo into separately-compiled projects. Past a certain size, makes the difference between "tsc takes 90 seconds" and "tsc takes 5 seconds for the changed package".
Sensible default tsconfig.json
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022"],
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"incremental": true,
"noEmit": true // most teams emit via Vite/esbuild/swc, not tsc
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts", "node_modules", "dist"]
}
You can extend @tsconfig/strictest from npm and override what you don't want — saves writing this every time.
How to migrate
For an existing codebase:
- Turn on
strictfirst. Fix the errors, even if it takes a week. - Then
noUncheckedIndexedAccess. The fewest false-positives, the most real bugs. - Add
noUnusedLocals/Parameters. Cleans the codebase as a side-effect. - Add the smaller flags one at a time.
noImplicitOverride,noFallthroughCasesInSwitch, etc. exactOptionalPropertyTypeslast. It cascades into a lot of files; do it once you have headroom.
Most teams reach a stable strictest config and stop. Past that, additional flags are diminishing returns.
What tsc actually does
tsc does two things, often confused:
- Type-check — read the program, error on type mismatches. Fast in modern setups.
- Emit — produce JS files corresponding to your TS files. Slower; usually skipped.
In a typical Vite/Next.js setup:
- Vite/Next compiles your TS to JS (via esbuild/swc, no type checking).
tsc --noEmitruns as a separate CI job to catch type errors.
This split is why your dev server can be instant but your CI still catches type bugs. The noEmit: true in the config above codifies that split.
What to put in tsconfig that isn't a strict flag
A short list of "obvious now, surprising later":
pathsfor any alias longer than../../.@/lib/xreads better than../../lib/xand survives moving files.types: ["vitest/globals"](or jest, or whatever) — pulls in your test framework's globals.jsx: "preserve"for React projects that bundle separately."react-jsx"if you want tsc to emit the new JSX runtime.
The takeaway
The cost of strict mode is one-time. The cost of not using it is unbounded — every any is a runtime crash waiting to happen. Configure once, never look back.
Tools in the wild
3 tools- clitype-coveragefree tier
Reports the percentage of your code that has explicit (non-`any`) types.
- library@tsconfig/strictestfree tier
Pre-baked tsconfig with every reasonable strict flag enabled — extend it.
- service
Toggle individual compiler flags and watch the same code error/pass in real time.