From loose any to a type-safe codebase
TypeScript's strict mode isn't a single switch, it's a bundle of eight compiler flags that together decide real type safety. Teams that enable strict only after years of loose code often face thousands of errors at once and abandon the migration. This article shows which flags deliver the most value for the least effort and how an existing codebase can realistically be converted step by step.
Table of Contents
- 1. What strict mode in tsconfig.json actually turns on
- 2. The individual strict flags in detail
- 3. Why strict mode really hurts when enabled late
- 4. Incremental migration: the pragmatic path for legacy code
- 5. tsconfig strategies: per directory and project references
- 6. strictNullChecks in practice: a before and after
- 7. CI gating: strict for new files, tolerant for legacy code
- 8. Editor tooling and team workflow during migration
- 9. Strict flags compared: effort versus benefit
- 10. Summary
- 11. FAQ
1. What strict mode in tsconfig.json actually turns on
strict in tsconfig.json is not a single flag but an umbrella switch for currently eight individual compiler options: alwaysStrict, noImplicitAny, noImplicitThis, strictBindCallApply, strictFunctionTypes, strictNullChecks, strictPropertyInitialization, and useUnknownInCatchVariables. Each of these options turns on one additional check that is silently skipped in loose mode. Important in practice: noUncheckedIndexedAccess, exactOptionalPropertyTypes, and noImplicitOverride are, despite their similar character, not part of strict and must be enabled individually.
Setting "strict": true instead of listing the flags individually means every TypeScript update automatically adds any new check the TypeScript team adds to the strict family in the future. That's usually desirable, but it can make a minor version bump of the typescript dependency in the CI pipeline fail unexpectedly, because new errors suddenly appear. Teams with high stability requirements therefore pin the TypeScript version exactly and review compiler updates deliberately instead of letting a caret range pull them in automatically.
2. The individual strict flags in detail
noImplicitAny forbids parameters, variables, and return values without a recognizable type and is usually the flag with the highest error count on first activation, because every missing annotation becomes visible. strictNullChecks separates null and undefined from all other types and forces developers to explicitly handle every potentially empty value before accessing it. strictFunctionTypes checks function parameter types contravariantly, which exposes unsafe callback assignments that compile without complaint in loose mode.
strictPropertyInitialization requires every class property to either be set in the constructor or explicitly typed as undefined, which prevents classic this.value bugs in services and repositories. strictBindCallApply checks the arguments of bind, call, and apply against the function's original signature. noImplicitThis flags this accesses with an unclear context, and useUnknownInCatchVariables has typed catch variables as unknown instead of any since TypeScript 4.4, preventing unchecked error access such as err.message without a prior type check.
// tsconfig.json - the "strict" flag family, spelled out explicitly
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
// Equivalent to "strict": true - listed individually for clarity
"alwaysStrict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"useUnknownInCatchVariables": true,
// NOT included in "strict" - opt in separately for extra safety
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"noImplicitOverride": true
}
}
3. Why strict mode really hurts when enabled late
The longer a codebase grows without strict, the more implicit assumptions hide inside function signatures that nobody questions anymore. A function that has returned any for three years gets called from twenty places in the project, each with its own implicit assumptions about the actual shape of the data. When strict is enabled retroactively, the compiler doesn't report twenty independent errors, it reports an error cascade where every fix uncovers new follow-up errors in related places.
The psychological effect is real: a pull request with 4000 new TypeScript errors is demoralizing for a team and, in practice, is almost always abandoned or postponed indefinitely. This is exactly why big-bang migrations fail so reliably. The economic cost is real but invisible: every month without strictNullChecks produces new null-unsafe spots that will also have to be migrated later. The right time for strict is project start. The second-best time is today, with a realistic, incremental plan instead of one giant refactor.
4. Incremental migration: the pragmatic path for legacy code
Instead of flipping strict globally, enable the flags one at a time and start with the cheapest: strictBindCallApply, noImplicitThis, and strictPropertyInitialization cause only a manageable number of errors in most codebases. noImplicitAny and strictNullChecks follow after that, ideally file by file rather than project-wide. The // @ts-check directive at the top of a file even lets you type-check individual .js files ahead of time, before the whole project has moved to .ts and strict.
For spots that can't be cleanly resolved right away, // @ts-expect-error is preferable to the blanket // @ts-ignore: it suppresses the current error but itself fails once the error is fixed and the annotation becomes unnecessary. That keeps a migration list automatically up to date, without forgotten ignores lingering unnoticed. A weekly CI report of the remaining @ts-expect-error comments makes progress visible and measurably motivates the team.
// @ts-check
// legacy-pricing.js - incremental adoption before converting to .ts
/**
* @param {{ price: number, discountPercent?: number }} item
* @returns {number}
*/
function calculateFinalPrice(item) {
// @ts-expect-error - legacy callers still pass a string here, tracked in TICKET-482
const discount = item.discountPercent ?? "0";
return item.price - (item.price * Number(discount)) / 100;
}
// Once every caller passes a real number, remove the @ts-expect-error
// line above - the build fails automatically if it becomes unused,
// which is exactly the signal that this call site is ready to migrate.
5. tsconfig strategies: per directory and project references
A proven structure for mixed codebases separates strict new code from loose legacy code using two tsconfig.json files instead of one global configuration. A tsconfig.base.json defines shared compiler options like target and module, while tsconfig.strict.json additionally sets strict: true and includes only new directories like src/modules/. Legacy code under src/legacy/ stays on the loose base configuration for now, without a single build step ever mixing the two worlds.
Project references ("references": [...] with composite: true) go a step further and allow incremental builds per subproject, which noticeably reduces build time in monorepos, because tsc --build only recompiles changed references. For smaller projects, a simpler pattern is usually enough: two include globs in separate configuration files, checked in sequence in the package.json script with tsc -p tsconfig.strict.json --noEmit and tsc -p tsconfig.legacy.json --noEmit.
// tsconfig.strict.json - strict rules apply only to new modules
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"strict": true,
"composite": true,
"outDir": "./dist/modules"
},
"include": ["src/modules/**/*.ts"]
}
// tsconfig.legacy.json - existing code stays on loose settings for now
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"strict": false,
"noImplicitAny": false,
"outDir": "./dist/legacy"
},
"include": ["src/legacy/**/*.ts"]
}
6. strictNullChecks in practice: a before and after
Without strictNullChecks, null and undefined are assignable to every other type, which means the compiler completely misses one of the most common sources of runtime errors: accessing a property of a value that doesn't actually exist at runtime. This exact pattern produces the infamous Cannot read properties of undefined exception in production, often only weeks after deployment, when a particular record contains an empty field for the first time.
With strictNullChecks enabled, the compiler refuses exactly that access unless an explicit null check, optional chaining with ?., or a type guard covers the case. The migration effort almost always concentrates on the same pattern: functions that can return undefined but were treated as guaranteed present by the caller. Once these spots are fixed, the compiler automatically finds new null-unsafe accesses with every future change, without a reviewer having to check for it manually.
// BEFORE: compiles fine without strictNullChecks, crashes at runtime
function getPrimaryAddress(customer) {
const address = customer.addresses.find(a => a.isPrimary);
return address.city.toUpperCase(); // throws if no primary address exists
}
// AFTER: strictNullChecks forces the missing case to be handled explicitly
interface Address {
city: string;
isPrimary: boolean;
}
interface Customer {
addresses: Address[];
}
function getPrimaryAddress(customer: Customer): string {
const address = customer.addresses.find((a) => a.isPrimary);
if (!address) {
throw new Error("Customer has no primary address");
}
return address.city.toUpperCase();
}
7. CI gating: strict for new files, tolerant for legacy code
A global strict: true across the entire repository is rarely reachable right away, but a CI rule that only strictly checks new or changed files can usually be introduced within a day. The principle: git diff determines the changed .ts files against the target branch, and only those files are checked against the strict configuration, while the rest of the repository still compiles against the loose base configuration. That way the pipeline reliably prevents new violations without blocking existing code immediately.
It's important not to treat this rule as an honor system but as a hard CI gate: a pull request that adds a new file without type annotations must not merge. At the same time, the number of remaining legacy errors should be versioned in a baseline file, so the pipeline additionally fails if that number rises unexpectedly instead of falling. This pattern, known as a ratchet, prevents silent regressions without making a full migration a prerequisite for every single merge.
#!/usr/bin/env bash
# ci-strict-gate.sh - type-check only files changed vs. the target branch
set -euo pipefail
TARGET_BRANCH="${TARGET_BRANCH:-main}"
# Collect changed .ts files, excluding deletions
mapfile -t changed_files < <(git diff --name-only --diff-filter=d "origin/${TARGET_BRANCH}...HEAD" -- '*.ts' '*.tsx')
if [[ ${#changed_files[@]} -eq 0 ]]; then
echo "No TypeScript files changed, skipping strict gate."
exit 0
fi
echo "Type-checking ${#changed_files[@]} changed file(s) under strict mode:"
printf ' %s\n' "${changed_files[@]}"
# Run the strict project config, but only report errors in changed files
npx tsc -p tsconfig.strict.json --noEmit | grep -F "${changed_files[@]/#/}" && exit 1
echo "Strict gate passed."
8. Editor tooling and team workflow during migration
A multi-month migration lives or dies by the visibility of progress. VS Code and other language server clients show type errors inline in the editor before tsc is even invoked manually, which lets developers fix new violations immediately instead of discovering them only in pull request review. A per-directory tsconfig.json as described in the previous section ensures the editor automatically loads the strict configuration for new modules, without developers having to switch between projects manually.
ESLint rules like @typescript-eslint/no-explicit-any complement the compiler by flagging explicit any annotations that are allowed under noImplicitAny but deliberately reintroduce the same type unsafety. A weekly team ritual in which the number of remaining @ts-expect-error comments and disabled rules is discussed as a metric keeps the migration a visible, shared goal in the team's awareness instead of letting it degrade into a side project for individual developers.
9. Strict flags compared: effort versus benefit
Not every flag in the strict family causes the same migration effort, and not every one delivers the same safety gain. The following overview ranks the five most important flags by what can go wrong without them, what they concretely enforce, and how much realistic migration effort they take in a typical existing codebase.
| Flag | Without the flag: risk | With the flag: safety | Migration effort |
|---|---|---|---|
| noImplicitAny | any leaks in unnoticed, no type checking | Every parameter needs a recognizable type | Medium |
| strictNullChecks | null/undefined crash only at runtime | Compiler enforces null checks before access | High |
| strictFunctionTypes | Unsafe callback assignments compile | Parameter types are checked contravariantly | Low |
| strictPropertyInitialization | Properties stay undefined without warning | Compiler enforces initialization in the constructor | Low |
| strictBindCallApply | bind/call/apply without checking arguments | Arguments are checked against the original signature | Low |
The table shows a clear pattern: the three flags with the lowest migration effort can be enabled in a single afternoon in most codebases and deliver immediate benefit. noImplicitAny and strictNullChecks, on the other hand, need a real plan, because they reach deep into existing function signatures. Anyone who wants to start small should enable the three cheap flags first, build team confidence in the process, and then tackle the two more expensive flags with a realistic timeline.
Mironsoft
TypeScript tooling, build scripts, and headless integrations for Magento stores
Ready to safely migrate an existing codebase to strict mode?
We analyze your TypeScript codebase, identify the flags with the biggest safety gain, and plan an incremental migration that won't block your team for weeks, from tsconfig strategy to CI gating for new files.
Strict Audit
Analysis of your tsconfig.json and prioritization of flags by effort and benefit
Migration Plan
Incremental activation per directory without a big-bang refactor
CI Integration
Strict gating for new files, legacy code left untouched for now
10. Summary
Strict mode in TypeScript solves a core problem: implicit assumptions about types, null values, and function signatures become explicit, compiler-checked contracts. noImplicitAny and strictNullChecks deliver the biggest safety gain but also cause the biggest migration effort in existing code. strictFunctionTypes, strictPropertyInitialization, and strictBindCallApply, on the other hand, can usually be enabled within a few hours and deliver immediate benefit without major refactoring work.
The decisive difference between successful and failed migrations rarely lies in the technical difficulty of the individual flags, but in avoiding big-bang refactors. Per-directory tsconfig.json files, // @ts-expect-error as a trackable transitional solution, and a CI gate for new files allow strict mode to be introduced incrementally, without having to block existing code immediately. That way the migration stays a continuous process instead of a one-time, risky mega-project.
Strict Mode in TypeScript, the Key Takeaways
noImplicitAny & strictNullChecks first
Biggest safety gain for manageable effort, covers the most common runtime errors.
Migrate per directory
Project references and per-folder tsconfig.json avoid big-bang migrations.
CI gating instead of force
New files strict, legacy code temporarily exempt via a baseline.
Not a substitute for tests
Strict mode finds type errors, not logic errors. Tests remain mandatory.