DEV Community

Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

TypeScript strict mode: the 6 tsconfig options that actually matter in production and when to enable them

TypeScript strict mode: the 6 tsconfig options that actually matter in production and when to enable them

There's a scene that plays out over and over. Someone sets up a new project, tells everyone "we're using strict TypeScript," and drops strict: true into tsconfig.json. Everyone nods. CI compiles. And three months later there's a production bug that TypeScript could have caught if someone had bothered to enable noUncheckedIndexedAccess.

My take is blunt: strict: true is a comfortable shortcut that enables six reasonable flags but leaves out two options that, in my experience, prevent more silent bugs than half the base group combined. The problem isn't strict: true itself — it's that most people enable it and feel like they're done.

This post isn't "enable strict and move on." It's a flag-by-flag breakdown: what each one does, what kind of error it prevents, and the sensible order for migrating a codebase that doesn't have them all enabled yet.


What strict: true includes — and what it doesn't

According to the official TypeScript documentation, strict: true is a shorthand that enables this set of flags:

  • strictNullChecks
  • strictFunctionTypes
  • strictBindCallApply
  • strictPropertyInitialization
  • noImplicitAny
  • noImplicitThis
  • useUnknownInCatchVariables (since TypeScript 4.4)
  • alwaysStrict (emits "use strict" in JS output)

What it does not enable by default:

  • noUncheckedIndexedAccess
  • exactOptionalPropertyTypes
  • noImplicitOverride
  • noPropertyAccessFromIndexSignature

That second group doesn't live under the strict umbrella. They're independent flags that TypeScript chose not to include because they generate a lot of new errors in existing codebases. That doesn't make them optional for production — it means the language designers made a conservative call. You can choose differently.


The 6 options with the highest real-world impact

1. strictNullChecks — the most important one in the base group

Without this, null and undefined are assignable to any type. With it enabled:

// Without strictNullChecks: compiles without error
function getUsername(user: User): string {
  return user.name; // user could be null
}

// With strictNullChecks: the compiler forces you to handle the case
function getUsername(user: User | null): string {
  if (!user) throw new Error("User not found");
  return user.name;
}
Enter fullscreen mode Exit fullscreen mode

If you can only pick one flag to enable today, this is it. The vast majority of runtime crashes in TypeScript apps that don't have this enabled share a common signature: Cannot read properties of undefined.

No debate here. If you don't have strictNullChecks, you don't have TypeScript — you have JavaScript with cosmetic types.

2. noImplicitAny — the second priority

When TypeScript can't infer the type of something and you haven't declared it, it has two options: error or silent any. Without this flag, it picks silent any.

// Without noImplicitAny: compiles. 'data' is implicit any.
function process(data) {
  return data.toUpperCase(); // no checking at all
}

// With noImplicitAny: error. You have to declare the type.
function process(data: string): string {
  return data.toUpperCase();
}
Enter fullscreen mode Exit fullscreen mode

Implicit any is like a hole in your type system. You don't see it, it doesn't warn you, and it spreads. noImplicitAny closes that hole.

3. strictFunctionTypes — for anyone working with callbacks and generics

This flag makes TypeScript check function parameter types contravariantly instead of bivariantly. It's the most technical flag in the group and the one fewest people actually understand — but it matters when you're passing callbacks between layers of the application.

type Handler = (event: MouseEvent) => void;

// Without strictFunctionTypes: this compiles even though it's unsafe
const handler: Handler = (event: Event) => {
  console.log((event as MouseEvent).clientX); // manual cast, real risk
};

// With strictFunctionTypes: error. MouseEvent is not assignable to Event in parameter position.
Enter fullscreen mode Exit fullscreen mode

In a React codebase with lots of event handlers, this flag catches function assignments that look reasonable but silently lose type information at runtime.

4. useUnknownInCatchVariables — the underrated one in the base group

Before TypeScript 4.4, the error in a catch block was any. With this flag enabled, it's unknown, which forces you to verify its shape before using it.

try {
  await fetchData();
} catch (error) {
  // Without useUnknownInCatchVariables: error is 'any'
  // With useUnknownInCatchVariables: error is 'unknown'

  if (error instanceof Error) {
    // Now you can safely access error.message
    console.error(error.message);
  } else {
    console.error("Unknown error", error);
  }
}
Enter fullscreen mode Exit fullscreen mode

In systems where error handling actually matters — authentication, external integrations, payment processing — this flag stops you from assuming the shape of an error without validating it first. strict: true enables it since TS 4.4, but it's worth understanding why it exists.

5. noUncheckedIndexedAccess — the one that prevents the most bugs outside the base group

This is the one strict: true doesn't enable, and the one you should care about most. When you access an array by index or an object by string key, TypeScript by default assumes the value exists. With noUncheckedIndexedAccess, the returned type includes | undefined.

// tsconfig: noUncheckedIndexedAccess: true

const items = ["first", "second", "third"];

const item = items[5]; 
// Without noUncheckedIndexedAccess: item is 'string'
// With noUncheckedIndexedAccess: item is 'string | undefined'

// Now the compiler forces you to check before using it:
if (item !== undefined) {
  console.log(item.toUpperCase()); // ✅
}

// Without the check: compilation error
// console.log(item.toUpperCase()); // ❌ Object is possibly 'undefined'
Enter fullscreen mode Exit fullscreen mode

Same behavior applies to index signatures:

const map: Record<string, number> = { a: 1 };

const value = map["b"];
// Without noUncheckedIndexedAccess: value is 'number'
// With noUncheckedIndexedAccess: value is 'number | undefined'
Enter fullscreen mode Exit fullscreen mode

The official docs are clear on this. Why isn't it in strict? Because it generates a lot of errors in existing codebases where index access is everywhere and nobody validates it. But that doesn't make it optional if you want real coverage.

In scenarios involving Prisma query results, external API responses cast to arrays, or configuration read from JSON — this flag catches exactly the class of bug that shows up late, in production, the first time the array arrives empty.

6. exactOptionalPropertyTypes — the most undervalued of all

This is the second one most people ignore, and the one that breaks things most subtly. Without this flag, TypeScript treats undefined as a valid value for an optional property. With it, there's a real difference between "the property might not be there" and "the property is there and equals undefined."

interface Config {
  timeout?: number; // optional property
}

// Without exactOptionalPropertyTypes:
// These two assignments are equivalent to TypeScript:
const a: Config = {};                    // timeout doesn't exist
const b: Config = { timeout: undefined }; // timeout exists but is undefined

// With exactOptionalPropertyTypes:
const c: Config = { timeout: undefined }; // ❌ Error
// Type 'undefined' is not assignable to type 'number'
// because 'timeout?' means 'might not be present', not 'can be undefined'
Enter fullscreen mode Exit fullscreen mode

Why does this matter? Because there's an operational difference between a missing key and a key with value undefined. In JSON serialization, in object spreads, in Prisma updates — the behavior differs. exactOptionalPropertyTypes makes TypeScript understand that distinction.


The order to migrate an existing codebase

If you're adding this to a project that already has code, the sensible order is:

Step 1: strictNullChecks          → most errors, highest impact, but they're the most urgent ones
Step 2: noImplicitAny             → second batch of errors, easier to resolve
Step 3: strict: true              → enables the rest of the base group all at once
Step 4: noUncheckedIndexedAccess  → new errors, but they're exactly the ones you wanted to see
Step 5: exactOptionalPropertyTypes → last, requires really understanding your data model
Enter fullscreen mode Exit fullscreen mode

A useful strategy for large projects is to enable flags with temporary // @ts-expect-error comments and resolve them file by file. Another is to use skipLibCheck: true during migration so you're not blocked by dependency types that haven't been updated yet.

// tsconfig.json — progressive migration config
{
  "compilerOptions": {
    // Step 1: start here
    "strictNullChecks": true,

    // Step 2: once the project compiles with the above
    "noImplicitAny": true,

    // Step 3: enable the full base group
    "strict": true,

    // Steps 4 and 5: after stabilizing the base group
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,

    // Temporary during migration:
    "skipLibCheck": true
  }
}
Enter fullscreen mode Exit fullscreen mode

The mistakes people make most often when migrating

Enabling everything at once and then giving up. CI explodes with 400 errors and someone decides "TypeScript strict is too restrictive." The problem isn't the flag — it's the order.

Using as to silence errors instead of fixing them. Every as unknown as WhateverTypeIWant is a type debt. It pushes the error to runtime and makes the migration purely cosmetic.

// This isn't a migration, it's a disguise:
const result = fetchUser() as User; // ❌ Ignores that fetchUser might return null

// This is:
const raw = await fetchUser();
if (!raw) throw new Error("User not found");
const result: User = raw; // ✅
Enter fullscreen mode Exit fullscreen mode

Ignoring the two flags outside strict. This is the most common mistake and the one that motivated this post. A lot of teams declare they're using strict TypeScript without knowing that noUncheckedIndexedAccess isn't included in that preset.

Enabling exactOptionalPropertyTypes without reviewing Prisma updates. In Prisma, updates use optional properties extensively. With this flag, patterns that used to compile stop doing so. That's not a blocker — it's a signal that your data model was imprecise. But it's worth knowing that's where that batch of errors is going to land.


What you can't conclude from this alone

This analysis is based on the official documentation and well-known TypeScript patterns. What you can't infer from here:

  • How many errors it'll generate in your specific codebase. You only know that by running tsc --noEmit with each flag enabled.
  • Whether exactOptionalPropertyTypes is worth the cost in a project with Prisma v5 and no prior refactors. It can be a lot of work for marginal value if the data model is already well-typed another way.
  • Whether there are incompatibilities with third-party libraries that don't handle noUncheckedIndexedAccess well. skipLibCheck: true mitigates this but doesn't eliminate it.

Deciding when to enable each flag requires running the compiler on your own code and reading the errors. No shortcuts here.


FAQ

Does strict: true enable noUncheckedIndexedAccess?
No. strict: true is a preset that enables eight specific flags documented in the official reference. noUncheckedIndexedAccess is not one of them. You have to enable it separately in tsconfig.json.

Which flag should I enable first if my project has none of them?
strictNullChecks. It's the one that prevents the largest class of runtime errors and is the logical prerequisite for the other flags to make sense. Without null checks, the rest is decoration.

Does noImplicitAny break explicit any usage?
No. noImplicitAny only penalizes the any TypeScript infers when it can't determine the type. If you write explicit any (const x: any = ...), it still compiles. That's intentional: sometimes you need to escape the type system. But at least you're doing it consciously.

Can I enable these flags progressively in a monorepo?
Yes. Each package in the monorepo can have its own tsconfig.json that extends a shared base. A common strategy is to enable the stricter flags in new packages and migrate the old ones incrementally. The risk is that types crossing package boundaries can land in gray zones during the transition.

Does exactOptionalPropertyTypes break object spreads?
It can, if you're using spreads to pass optional properties with value undefined. The compiler will flag those cases because there's a semantic difference between an absent property and a property with value undefined. In most cases, the fix is to use narrowing or conditional spreads instead of assuming undefined passes through transparently.

Is it worth enabling all of this in a project that already works?
Depends on the cost of the bugs you're trying to prevent. If the system handles authentication, financial data, or any kind of information where a silent error has real consequences — yes, the migration cost is worth it. If it's an internal prototype that never reaches users — maybe strict: true is enough for now. The criterion is the cost of the error, not the comfort of the setup. This ties directly into broader architectural decisions, the kind that come up in posts like the one on digital identity backend architecture: the flags aren't decoration, they're part of your system's security contract.


My position and the next concrete step

strict: true is the floor, not the ceiling. The preset exists to make adoption easy — not to end the conversation there.

The two flags with the highest impact outside the base group are noUncheckedIndexedAccess and exactOptionalPropertyTypes. The first closes the door on the most common class of error in array and map access. The second makes your type model reflect the real difference between "property absent" and "property with value undefined" — a distinction that matters in serialization, in Prisma, and in any code that receives data from the outside world.

What I don't buy is the "I enabled strict, we're good" attitude. It's the same energy as adding a healthcheck that only verifies the process responds — it gives you a sense of security that isn't measuring what you think it is.

The next concrete step: run tsc --noEmit with noUncheckedIndexedAccess: true on the project you're working on right now. Read the errors. If they're manageable, enable it. If there are 200+ errors, start with the most critical files. You don't need to fix everything at once — you need to know what you've been ignoring.


Original sources:


This article was originally published on juanchi.dev

Top comments (0)