TypeScript Strict Mode: Why Your Team Should Enable It and How to Survive the Transition

#TypeScript strict mode
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

A dashboard component ships to production on Monday morning. By Tuesday afternoon, a customer support ticket arrives: the page crashes with Cannot read properties of undefined (reading 'totalRevenue') whenever a newly registered user who has not completed onboarding loads the dashboard. The TypeScript compiler was happy. The type said User.companyMetrics: CompanyMetrics. The API returns null for users without a company, and the frontend code never checked.

This is the exact class of bug that TypeScript strict mode is designed to prevent, and it is one of the most common reasons teams who already use TypeScript still ship runtime errors. Turning on strict mode is not a cosmetic change. It is a commitment to a stricter contract between your types and your runtime, and on an existing codebase it surfaces hundreds of latent bugs that have been silently hiding behind lax defaults. This post explains what TypeScript strict mode actually enables, which flags deliver the highest return on investment, and a gradual adoption strategy that works for teams who cannot afford to freeze feature development for two weeks while they fix 2,000 compiler errors.

What TypeScript Strict Mode Actually Enables

Setting "strict": true in tsconfig.json is a meta-flag. It enables eight individual options at once, and understanding them individually matters because you will end up enabling them individually during a migration. As of TypeScript 5.x, the flags are: noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, alwaysStrict, noImplicitThis, and useUnknownInCatchVariables.

Two of these matter far more than the others in terms of both error volume and real-world impact. strictNullChecks forces null and undefined to be treated as distinct types rather than as values assignable to every type, which is what would have caught the dashboard bug above. noImplicitAny forces every parameter, return value, and variable without an explicit annotation and no inferrable type to be annotated rather than silently falling back to any. Together these two flags are responsible for the vast majority of errors you will see when first enabling strict mode on a legacy codebase, and they are also responsible for most of the runtime bugs strict mode prevents.

Two other flags deserve independent attention. strictPropertyInitialization forces class properties to be initialized in the constructor or declared as optional, catching a subtle family of bugs where a property is accessed before it has been assigned. useUnknownInCatchVariables types caught errors as unknown rather than any, forcing the code to narrow before accessing .message or .stack on something that might not actually be an Error. There is also a separate flag, exactOptionalPropertyTypes, which is not enabled by strict: true but which closes a specific hole in optional property handling—{ name?: string } normally accepts { name: undefined } as well as {}, which is sometimes not what you want. Enable it only after you have stabilized the rest.

Why Strict Null Checks Is the Most Important Flag You Will Ever Enable

Without strictNullChecks, TypeScript treats null and undefined as subtypes of every other type. A variable typed as string can legally hold null at runtime, and the compiler will not warn you.

// strictNullChecks OFF
interface User {
  email: string;
}

function getEmailDomain(user: User): string {
  return user.email.split('@')[1]; // Crashes at runtime if email is actually null
}

With strictNullChecks enabled, if User.email can be null, the type system forces the code to handle the case:

// strictNullChecks ON
interface User {
  email: string | null;
}

function getEmailDomain(user: User): string {
  if (user.email === null) {
    return '';
  }
  const parts = user.email.split('@');
  return parts[1] ?? '';
}

A secondary effect often catches teams by surprise: array indexing returns T | undefined once noUncheckedIndexedAccess is also enabled, and even without that flag, Array.prototype.find returns T | undefined. Code that used to assume a .find() result was always present now must handle the missing case explicitly. This is not a bug in TypeScript—it is TypeScript finally telling the developer about an assumption that was being made silently.

A Realistic Migration Strategy for Existing Codebases

The most common mistake teams make when enabling strict mode is flipping the flag in a pull request on a Friday afternoon and drowning in a sea of two thousand errors on Monday. A better strategy is incremental, with each step producing a codebase that compiles cleanly.

The first step is creating a second tsconfig—call it tsconfig.strict.json—that extends the main config and enables strict mode only for a whitelisted subset of files. Start with brand-new files and a small, well-isolated module you want to harden first. Use include to list the files under strict checking, and run both configs in CI: the main config must compile the whole codebase without errors, and the strict config must compile the whitelisted subset without errors. This lets you harden the codebase file by file without ever breaking the build.

// tsconfig.strict.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  },
  "include": [
    "src/domain/**/*.ts",
    "src/lib/payments/**/*.ts"
  ]
}

The second step is enabling one strict flag at a time at the whole-codebase level. noImplicitAny is usually the easiest—it catches missing annotations but rarely surfaces logic bugs. strictNullChecks is the hardest and most valuable. Turning it on across an established Next.js application typically produces between 200 and 2,000 errors depending on codebase size and existing discipline. Fix the errors in batches by module rather than by error count, because errors cluster around specific domain concepts (a user object that can be null, a config value read from the environment, a server action whose return type actually includes an error case). Fixing them together tends to reveal a cleaner shape for the domain type.

The third step is adopting noUncheckedIndexedAccess and exactOptionalPropertyTypes after the team is comfortable with the baseline strict flags. These catch more subtle bugs but require more refactoring to satisfy, and enabling them too early tends to generate resistance that slows the whole migration.

Patterns for Handling the Hardest Errors

Most strict mode errors fall into a handful of categories, each with a well-understood pattern.

For errors about possibly-null values, prefer type guards and early returns over non-null assertions. The ! operator (user.email!.split('@')[1]) silences the compiler without doing anything useful at runtime—the crash still happens, the warning just disappears. Reserve ! for cases where external knowledge the compiler cannot see guarantees the value is present, such as values that have already been validated by a runtime schema library like Zod or Valibot earlier in the request cycle.

For errors about unknown in catch blocks, introduce a small error-narrowing helper:

function getErrorMessage(error: unknown): string {
  if (error instanceof Error) return error.message;
  if (typeof error === 'string') return error;
  return 'Unknown error';
}

try {
  await processPayment(orderId);
} catch (error) {
  logger.error('Payment failed', { message: getErrorMessage(error), orderId });
}

For errors about uninitialized class properties, the right fix is almost always to initialize the property in the constructor, mark it optional with ?, or use the definite assignment assertion ! for properties that are genuinely initialized by a framework (Angular's dependency injection, TypeORM relations). Avoid the tempting pattern of giving properties default values that do not represent a valid state—an empty string for a user ID, for example, creates silent bugs downstream when code assumes any non-empty value it sees is real.

For large refactors in React and Next.js codebases, discriminated unions eliminate entire categories of null-check errors at the type level. A component that displays either a loading state, an error state, or data becomes much cleaner when its props force exactly one of those three states to exist:

type DashboardState =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; data: DashboardData };

function Dashboard({ state }: { state: DashboardState }) {
  if (state.status === 'loading') return <Spinner />;
  if (state.status === 'error') return <ErrorBanner message={state.message} />;
  return <Panel data={state.data} />;
}

The compiler now guarantees that state.data is only accessed on the success branch, and state.message only on the error branch. No runtime checks, no optional chaining, no defensive defaults—the types have done the work.

Keeping Strict Mode On: Preventing Regression

Once strict mode is enabled, the harder job is keeping it on. Two habits prevent regression. First, enforce the strict config in CI with a separate build step that fails the pull request if new or modified files introduce strict errors. This matters because code review alone is not reliable for catching a reintroduced any or a missing null check. Second, add ESLint rules (@typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion) at severity warn or error to keep escape hatches visible during review rather than hidden.

Teams with a growing codebase also benefit from tracking strict coverage explicitly. A small script that counts the files included in the strict config and reports the percentage of the codebase under strict checking can be added to a dashboard. Progress becomes visible and motivating rather than abstract, which matters a great deal for migrations that last several months.

The Payoff

The most common resistance to enabling TypeScript strict mode is that it slows down feature work in the short term. In our experience running code quality audits on Next.js and Node.js codebases, the productivity hit is real but lasts two to six weeks, and teams recover quickly. What remains permanently is an entire category of null-related bugs that stop reaching production, a codebase where refactoring is safer because the types honestly describe runtime behavior, and new developers who can onboard by reading the types rather than running the app and watching for errors.

For teams inheriting a legacy TypeScript codebase written before strict mode or strict null checks became standard, this migration is one of the highest-leverage investments available. The code becomes safer, the types become honest, and the runtime errors that frustrate users and consume support capacity start to disappear. If your team is considering this transition, or wrestling with a codebase where the types and the runtime have drifted apart, we help European teams adopt strict mode on production Next.js and Node.js applications without freezing feature development. Contact us at hello@wolf-tech.io or visit wolf-tech.io for a free consultation.