Step 1 - Start with a modern baseline
Use this as a production-friendly starting point:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true
}
}
Adjust lib, module, and moduleResolution to your runtime. A Next.js app, a Node service, and a library package do not always want the same settings.
Step 2 - Add flags that catch real bugs
Layer in the flags that change day-to-day correctness:
{
"compilerOptions": {
"useUnknownInCatchVariables": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true
}
}
What these settings force:
| Flag | Human meaning |
|---|
useUnknownInCatchVariables | Prove what an error is before reading fields |
noImplicitOverride | Make inheritance changes visible |
noFallthroughCasesInSwitch | Stop accidental switch fallthrough |
noImplicitReturns | Return on every path |
noUncheckedIndexedAccess | Treat missing array/object entries as possible |
exactOptionalPropertyTypes | Distinguish omitted from explicitly undefined |
noPropertyAccessFromIndexSignature | Make dictionary access look like dictionary access |
Step 3 - Stabilize import behavior
Modern TypeScript projects often benefit from:
{
"compilerOptions": {
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true
}
}
Use these when your toolchain supports them. They reduce compiler magic and make each file safer to transform independently.
The main tradeoff is that you may need to be more explicit about type-only imports:
import type { User } from "./types";
import { loadUser } from "./load-user";
That small cost pays off when bundlers, tests, and typecheckers agree about which imports exist at runtime.
Step 4 - Migrate without breaking the team
Do not turn on every flag in a large legacy repo and then ask everyone to stop shipping features. Use a ratchet:
- Enable
strict and fix application boundaries first. - Add
noImplicitReturns and noFallthroughCasesInSwitch. - Add
noUncheckedIndexedAccess. - Add
exactOptionalPropertyTypes. - Add import/module strictness after the runtime path is clear.
Commit each stage separately. If a stage causes noise, the team can review the exact tradeoff.
Step 5 - Fix strictness failures properly
Avoid these shortcuts:
- replacing real types with
any; - adding non-null assertions everywhere;
- widening optional properties until the compiler gives up;
- disabling flags in files that touch external data.
Prefer boundary fixes:
type ApiUser = {
id?: string;
email?: string;
};
function parseApiUser(input: ApiUser) {
if (!input.id) throw new Error("Missing user id");
if (!input.email) throw new Error("Missing user email");
return {
id: input.id,
email: input.email,
};
}
The parser narrows uncertainty once. The rest of the app gets a cleaner object.
Step 6 - Add CI gates
Use separate commands:
npm run lint
npm run typecheck
npm run build
typecheck should usually run tsc --noEmit. Build tools can miss type issues when they transpile quickly, and lint rules cannot replace the compiler.