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:
strictNullChecksstrictFunctionTypesstrictBindCallApplystrictPropertyInitializationnoImplicitAnynoImplicitThisuseUnknownInCatchVariables(since TypeScript 4.4)alwaysStrict(emits"use strict"in JS output)
What it does not enable by default:
noUncheckedIndexedAccessexactOptionalPropertyTypesnoImplicitOverridenoPropertyAccessFromIndexSignature
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;
}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();
}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.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);
}
}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'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'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'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
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
}
}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; // ✅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 --noEmitwith each flag enabled. - Whether
exactOptionalPropertyTypesis 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
noUncheckedIndexedAccesswell.skipLibCheck: truemitigates 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:
- TypeScript Strict Mode Docs: https://www.typescriptlang.org/tsconfig#strict
- TypeScript noUncheckedIndexedAccess: https://www.typescriptlang.org/tsconfig#noUncheckedIndexedAccess
Related Articles
Digital identity backend architecture: the decisions tutorials skip
Auth tutorials show you the happy path. The real problems in digital identity show up in revocation, state-change propagation, and the trust model. A decision guide from the inside.
System prompts for production agents: the format that survived 3 redesigns
A system prompt isn't documentation for the model — it's a contract. After several redesigns, I landed on a format with fixed sections, explicit limits, and dynamically injected context. Here's what survived and why.
Docker healthchecks: what they actually measure and what you shouldn't promise
A healthcheck that only says "the process is responding" can hide serious business-level failures. Let's break down what the HEALTHCHECK instruction actually promises, where the standard recipe falls apart, and how to use it as the limited operational signal it really is — not as a guarantee of heal
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.