You have a TypeScript codebase, but it is not really TypeScript. strict is off, half the files have any annotations from an automated migration, and the team has stopped seeing TS errors as a signal because there are too many. The cleanup feels like a six-month project nobody has time for.
Incremental TypeScript strictness is the only realistic way out. You enable strict flags one at a time, fix the warnings each one produces, and stay shipping the whole way. Done right, it takes weeks to months — not the year a single big-bang migration would.
The Strict Flags Worth Caring About
TypeScript's strict: true is actually nine separate options. They are independent; you can adopt them one by one.
| Flag | What it catches |
|---|---|
noImplicitAny |
Untyped parameters and variables |
strictNullChecks |
Forgetting that values can be null or undefined |
strictFunctionTypes |
Function parameter contravariance |
strictPropertyInitialization |
Class properties used before initialization |
noImplicitThis |
this of type any |
alwaysStrict |
'use strict' on every file |
useUnknownInCatchVariables |
catch (e) where e: unknown instead of any |
noUncheckedIndexedAccess |
arr[0] typed as T | undefined |
exactOptionalPropertyTypes |
Distinguishes prop?: string from prop: string | undefined |
The two with the biggest impact are strictNullChecks and noImplicitAny. Most existing TypeScript codebases have these on. The others vary.
The Order to Adopt
A sequence that works for most codebases:
noImplicitAnyif you do not have it. The biggest signal-to-noise ratio.strictNullChecksif you do not have it. The second biggest.useUnknownInCatchVariables— small impact, easy fix.noImplicitThis— small impact, easy fix.strictFunctionTypes— rarely fires once the above are in place.strictPropertyInitialization— class-heavy codebases will fight this. Use!ordefinite assignmentto ease in.noUncheckedIndexedAccess— high impact, real ergonomic cost. Do this last.exactOptionalPropertyTypes— niche, often produces noise without much benefit.alwaysStrict— usually a no-op in modern codebases.
Skip the niche ones if they do not improve your code. Strictness is a tool, not a goal.
The Per-File Migration Pattern
If your codebase is large enough that flipping a flag breaks hundreds of files, the per-file approach is the only realistic path.
Most modern build setups (Vite, Next.js, plain tsc) let you have per-file strictness via // @ts-strict comments or via inline tsconfig in newer TypeScript. The cleaner pattern: maintain two tsconfigs.
// tsconfig.json
{
"compilerOptions": {
"strict": false
}
}
// tsconfig.strict.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true
},
"include": ["src/strict-files/**/*", "src/features/auth/**/*"]
}
Run both during CI. New code goes in strict-enabled directories. As old code gets touched, it gets converted and moved.
This is a real, sustained process — not a weekend project.
Tactics for the Noisy Flags
Handling strictNullChecks
This flag introduces hundreds of 'X' is possibly undefined errors. The fixes fall into a few patterns:
// Pattern 1: Use the non-null assertion when you know better than TS
const el = document.getElementById('app')!;
el.classList.add('ready'); // ! tells TS this is not null
// Pattern 2: Optional chaining + nullish coalescing
const name = user?.name ?? 'Anonymous';
// Pattern 3: Early returns
if (!user) throw new Error('User required');
console.log(user.name); // now TS knows user is non-null
The ! operator is a tool. Used sparingly, it is fine. Used everywhere, it is the new any. A rough heuristic: ! is okay when the assertion captures real knowledge (DOM elements that exist by contract); not okay when it is hiding a bug.
Handling noUncheckedIndexedAccess
This flag is the most disruptive to ergonomics. arr[0] is now T | undefined even if arr.length > 0. Common idioms break:
// Was fine, now errors
const first = items[0];
console.log(first.name); // first is possibly undefined
// Fixes
const first = items[0];
if (first) console.log(first.name);
// Or use .at() which is honest about it
const last = items.at(-1);
For codebases with lots of index access, this is genuinely painful. Adopt last, or accept the ergonomic cost as the price of safety.
The any Audit
Existing codebases have any annotations from automated migrations or "I'll fix it later." A useful first step is to ban new any while accepting existing ones.
// .eslintrc
{
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unsafe-assignment": "warn"
}
}
New code cannot introduce any. Existing any can be tracked but not fail CI. Over time, the count drops as files get refactored.
For tracking progress, generate a count:
grep -rn "any" --include="*.ts" --include="*.tsx" src/ | wc -l
Watch the number trend down. Stalling means the discipline is slipping.
Working With Untyped Dependencies
Sometimes a dependency does not have types, or its types are wrong. Options:
// 1. Declare types locally
declare module 'untyped-pkg' {
export function doThing(x: number): string;
}
// 2. Use DefinitelyTyped if available
npm i -D @types/lodash
// 3. Wrap the untyped boundary
import * as untyped from 'untyped-pkg';
interface ExpectedShape { /* ... */ }
const typed: { doThing: (x: number) => string } = untyped as any;
The wrapping pattern is most flexible. The risky type assertions live in one file; the rest of the code uses a clean, typed interface.
What to Stop Doing
A few patterns that prevent the migration from working:
as anyto silence errors. This propagates type erasure. Track these in a backlog.- Generic
Record<string, any>. Pick more specific types. If the shape is genuinely unknown, useRecord<string, unknown>. - Type definitions that lie. A function annotated as
(x: string) => numberthat occasionally returns undefined is worse than no annotation. Fix the type. - Whole files marked
// @ts-nocheck. This excludes them from the migration entirely. Replace with per-line@ts-ignoreor fix the real issue.
When You Are Done
The migration is "done" when:
strict: trueis enabled globally- No
// @ts-nocheckdirectives in production code - New code does not introduce
any - The
anycount is trending down or zero
For most codebases, this takes a year of consistent effort. The reward is real: types catch bugs at compile time, refactoring is safer, and IDE support is dramatically better.
When It Is Not Worth It
If your codebase is small, in maintenance mode, or going to be rewritten in 18 months, the migration might not be worth completing. Adopt noImplicitAny and strictNullChecks (the high-value ones), skip the others, and call it good.
Perfect TypeScript strictness is a feature of long-lived codebases that benefit from compile-time safety. For short-lived code, the ROI is much lower.
Working on a TypeScript migration that has stalled out somewhere between "JS with types" and "real TypeScript"? We help teams plan incremental strictness rollouts that finish without freezing feature work. scopeforged.com