"strict": true in your tsconfig.json is one line that does eight different things, and not all of them are equally useful. Some of those flags catch real production bugs before they ship. Some of them produce dozens of low-value errors that train your team to use as any reflexively. The TypeScript strict mode flags story is not "turn them all on, suffer, ship better code". It is more nuanced. We run TypeScript across draftedby.com, the lesson-planning monorepo, jdchess, and Carriva. Here is the ranked list we apply, the migration order we use, and the flags we deliberately leave off.
What "strict" actually expands to
"strict": true is a meta-flag that turns on these underlying flags:
strictNullChecksnoImplicitAnystrictBindCallApplystrictFunctionTypesstrictPropertyInitializationalwaysStrictnoImplicitThisuseUnknownInCatchVariables
It does not turn on, but probably should:
noUncheckedIndexedAccessexactOptionalPropertyTypesnoImplicitOverridenoFallthroughCasesInSwitchnoUnusedLocalsnoUnusedParameters
The bug-catching power of TypeScript is not in the eight strict flags. It is in the four to six "extra strict" flags that strict mode does not include. If you stopped at "strict": true and called it done, you left most of the value on the table.
The ranked list
Top to bottom, by our judgment of signal-to-noise on real production work.
Tier 1: enable everywhere, no exceptions
strictNullChecks is the entire point of TypeScript. It distinguishes string from string | null | undefined. Without it, you have a worse Java. With it, you catch the entire class of "cannot read property X of undefined" bugs at compile time. If you are not running this, you are not running TypeScript.
noImplicitAny stops you from accidentally writing functions whose parameters are silently any. Disable this and TypeScript becomes a documentation tool, not a verification tool.
strictFunctionTypes catches a subtle class of variance bugs in callbacks. Low noise, real catch rate.
useUnknownInCatchVariables types your catch (err) as unknown instead of any, forcing you to narrow before using. The first time you remember "yes, the error here might not be an Error instance", this flag has paid for itself.
Tier 2: enable, with a migration plan
noUncheckedIndexedAccess is the single highest-value extra flag. It changes the type of arr[i] from T to T | undefined, forcing you to handle the case where the index is out of bounds.
This is initially noisy on existing codebases. Every arr[0] becomes a candidate error. The noise is good. About 70% of the new errors are real bugs (or potential bugs in adversarial input cases), 30% are nuisance cases where you know the array is non-empty.
// Without the flag:
const first = arr[0]; // type T
first.toUpperCase(); // compiles, may crash
// With the flag:
const first = arr[0]; // type T | undefined
first.toUpperCase(); // compile error, must guard
We enabled this on Carriva 8 months in. It found 14 latent bugs in the first day. Worth it.
exactOptionalPropertyTypes distinguishes { x?: string } (the property may be missing) from { x: string | undefined } (the property is present but may be undefined). These are different shapes and JSON serializers handle them differently.
This flag is medium-noise. Most codebases have casual mixing of "missing" and "explicit undefined". Enabling it forces you to be precise. The catch is real but the migration cost is non-trivial.
noImplicitOverride forces you to explicitly mark methods that override a parent class method. Catches the bug where you rename a method on a parent class and forget that a subclass was overriding it.
Low noise, real value. We turn this on by default.
Tier 3: enable selectively
noFallthroughCasesInSwitch catches missing break or return in switch statements. The catch rate is real but switch statements are rare in modern code. We enable it because the cost is zero.
noUnusedLocals / noUnusedParameters catches unused variables. Useful but it can fight with intentionally-typed-but-unused destructure patterns. We disable noUnusedParameters because the noise on intentional underscores is annoying. We enable noUnusedLocals.
Tier 4: disable, opinionated
strictPropertyInitialization is the one strict flag we frequently turn off, especially for class-heavy code that uses dependency injection or that initializes properties in a method called from the constructor. The errors it produces are mostly false positives in our patterns. We disable it.
noPropertyAccessFromIndexSignature is a flag we turn off. It is intended to force you to use bracket notation for index-signature access, but the result is verbose code without proportional benefit.
noImplicitReturns is a flag we turn on. It catches functions that conditionally return values. Low noise.
The recommended tsconfig.json for new projects
Here is the configuration we use for new Drafted By projects.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": false,
"strictPropertyInitialization": false,
"useDefineForClassFields": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"isolatedModules": true,
"incremental": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", ".next", "dist"]
}
Two notes on this:
skipLibCheck: trueis non-negotiable in any project with more than 5 dependencies. Without it, your build slows by 3x and your error list is dominated by errors in third-party type definitions you cannot fix.isolatedModules: trueis required for Next.js, Vite, and any modern bundler that compiles each file independently.
The migration order for an existing codebase
Turning on flags one at a time. The right order minimizes pain.
strict: true(eight flags at once, but the existing codebase probably already runs this).noImplicitOverride,noFallthroughCasesInSwitch,noImplicitReturns(low-noise, fast wins).noUncheckedIndexedAccess(high-value, several days of cleanup).exactOptionalPropertyTypes(highest cost, do last).
For each flag:
- Enable it.
- Run
tsc --noEmitto see the new errors. - Fix in batches by error class, not file by file.
- Use
// @ts-expect-error TODO: tighten thisfor the few cases you cannot fix immediately, with a real ticket. - Commit when clean.
We have done this exact migration on three projects. It is one to three weeks of part-time work for each major flag, depending on codebase size.
The bug-catching record
Honest reporting from our own codebases.
On the lesson-planning monorepo, after enabling noUncheckedIndexedAccess, we found:
- 6 places where
parts[2]was assumed to exist after a string split that could yield 1 or 2 parts. - 3 places where the first element of an array filter result was used without a length check.
- 2 places where an iterator returned
undefinedat the boundary that we treated as the type T.
All three classes of bug had appeared in production at least once.
After enabling exactOptionalPropertyTypes on Carriva, we found:
- 4 places where API responses contained explicit
nullfor optional fields, and our consumer code only handled "missing". - 2 places where Zod parsing was producing
{ x: undefined }and downstream serialization was including the key.
These are the kinds of bugs that pass tests because tests are written to match the implementation. The compiler does not care about your tests; it cares about the type contract.
Strict-mode flags are not punishment. They are the cheapest code review you will ever pay for.
What we deliberately leave off
A few flags we do not use:
strictPropertyInitializationas noted, fights with our patterns.noUncheckedSideEffectImportsis too coarse for our import-heavy codebases.alwaysStrictis on by default in modern TypeScript and we do not touch it.noImplicitAnyexceptions for declaration files we sometimes need to relax on third-party types.
The rule is: if a flag produces more false positives than true positives in our actual codebase, we turn it off. We do not run flags as a status game. We run them because they catch bugs.
ESLint vs TypeScript
A natural question: is this not what ESLint is for?
ESLint and TypeScript catch different bugs. TypeScript catches type errors. ESLint catches stylistic and pattern errors (unused vars, prefer-const, no-unused-imports). Some categories overlap (no-unused-vars exists in both); for those, we prefer TypeScript's noUnusedLocals because it sees the type system, not just the AST.
ESLint with the typescript-eslint plugin is also valuable. We use both. The TypeScript compiler is our gate; ESLint is the cleanup.
If you are choosing where to invest first, max out the TypeScript compiler flags. ESLint is a complement, not a substitute.
How this fits with our other tooling
Strict TypeScript is one piece of our overall reliability story. The schema discipline we covered in our Postgres jsonb vs columns writeup pairs with strict TypeScript: the Zod schema at the API boundary plus typed columns in the database plus strict types in the application gives you three layers of agreement. When all three layers say the same thing, your runtime is dramatically less surprising.
We also use strict types in our prompt library for LLM calls; the typed prompt library pattern is described in our typed prompt library piece. Strict typing on the prompt inputs and outputs catches the LLM-integration bugs that would otherwise show up in production.
And on the deployment side, the strict-mode build is what gives us confidence that a Docker container built from our standalone Next.js output (covered in our Next.js 15 standalone Docker writeup) will actually run. The compiler is the first line of defense before the container even gets built.
A note on any and unknown
A discipline that pays off. Whenever you would write any, write unknown instead and narrow it. The cost is small (a type narrowing assertion) and the upside is real (you cannot accidentally call methods on it without thinking).
The exception: third-party library boundaries where you cannot influence the types. There, any is sometimes pragmatic. We confine these to a small number of "shim" files that we treat as the impure boundary, with the rest of the codebase pure.
// Bad
const config: any = JSON.parse(raw);
const port = config.port; // type any, no checks
// Good
const config: unknown = JSON.parse(raw);
if (typeof config === "object" && config !== null && "port" in config) {
const port = (config as { port: number }).port;
// ...
}
// Better, with a schema validator
const Config = z.object({ port: z.number() });
const config = Config.parse(JSON.parse(raw));
The "better" pattern is what we ship. The compile-time strictness pairs with runtime validation; both layers agree.
What we would test first
If you are trying to harden an existing codebase:
- Run
tsc --noEmitwith your current config and check the error count. - Add
noUncheckedIndexedAccess: trueand re-run. - The new error count is your TypeScript debt. The first 30 errors typically include 5 to 10 real bugs.
- Fix those 30. Then enable the flag permanently.
- Repeat with
exactOptionalPropertyTypes.
You will not enable all flags in one afternoon on a real codebase. You will enable them over weeks. The investment is real and the return is the kind of subtle production bug that only the type system can catch.
TL;DR
Strict mode is the entry-level. noUncheckedIndexedAccess is where the real bugs live. exactOptionalPropertyTypes is the second-most-valuable flag nobody runs. Skip the flags that fight your patterns. Migrate in stages, never all at once. The TypeScript strict mode flags story is not "all on or all off". It is "match the flags to your codebase, and pay for the noise that catches real bugs".



