Strict Mode in TypeScript: Which Flags Actually Matter
AI generated
<T>
type
TypeScript · Strict Mode · tsconfig · Type Safety
Strict Mode in TypeScript: Which Flags Actually Matter
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.

14 min read strictNullChecks · noImplicitAny · strictFunctionTypes TypeScript 5.x · tsconfig.json · CI/CD

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.

11. FAQ: Strict Mode in TypeScript

1What exactly does strict mode in TypeScript turn on?
Eight individual flags: alwaysStrict, noImplicitAny, noImplicitThis, strictBindCallApply, strictFunctionTypes, strictNullChecks, strictPropertyInitialization, and useUnknownInCatchVariables.
2Which single flag has the biggest effect on code quality?
strictNullChecks, because it covers the most common source of runtime errors: accessing properties of a value that can be null or undefined. Migration effort is correspondingly high.
3Why is strictNullChecks the most painful flag to enable late?
Without the flag, null/undefined are assignable to every type. Grown code contains countless implicit assumptions as a result, which all become visible as an error cascade at once when enabled.
4Can I enable strict mode only for new files?
Yes, via a separate tsconfig.strict.json with include limited to new directories. A CI gate can additionally ensure new files always stay type-safe.
5What is the difference between noImplicitAny and strict: true?
noImplicitAny is one of eight flags in strict: true. Alone it only forbids missing type annotations, but covers neither null safety nor function variance.
6How do I realistically migrate a large existing codebase to strict mode?
Enable flags individually, start with the cheapest, use per-directory tsconfig for new modules, @ts-expect-error as a transitional solution, and a CI gate against new violations.
7Is noUncheckedIndexedAccess part of strict: true?
No. noUncheckedIndexedAccess, exactOptionalPropertyTypes, and noImplicitOverride are standalone flags and must be enabled separately.
8What does strictFunctionTypes concretely change?
Checks parameter types contravariantly instead of bivariantly, exposing unsafe callback assignments with an overly broad parameter type.
9How do I prevent legacy files from blocking the CI pipeline?
Through a baseline file with the current error count. The pipeline only fails if that number rises, a ratchet mechanism against regressions.
10Is strict mode worth it in small projects too?
Especially there, because the effort is low and implicit assumptions never get a chance to accumulate in the first place. For new projects, strict: true is the standard from day one.