Strict Mode in TypeScript: What Gets Enabled
1. Overview
"strict": true is the most important line in tsconfig.json. But many people enable it without knowing what it actually turns on. This article goes through each flag that strict activates, the actual errors each flag catches, and a few additional flags worth enabling. At the end, there’s a way to migrate an old project to strict without having to fix thousands of errors all at once.
2. strict is a group of flags
strict is not a single check. It’s a master switch that enables a group of flags all at once:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"strict": true
}
}
With TypeScript 5.x and 6.x, strict: true is equivalent to:
{
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"strictBuiltinIteratorReturn": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true
}
}
There are two things you need to remember:
- Each time TypeScript adds a new “strict” check, like
useUnknownInCatchVariablesin 4.4 orstrictBuiltinIteratorReturnin 5.6, that check is automatically enabled bystrict. Therefore, upgrading TypeScript can introduce new errors even if you don’t touch your code. - You can enable
strictand then disable individual flags. Specific flags always override the global flag.
TypeScript 6.0 changed the default value of strict to true. Still, you should explicitly specify this flag in tsconfig.json so whoever reads the config doesn’t have to guess.
To try the examples below, you just need to install TypeScript and run tsc --noEmit:
npm install --save-dev typescript
npx tsc --noEmit
3. The most important flags
3.1. noImplicitAny
When TypeScript can’t infer a type, it implicitly assigns any. This flag turns that into an error.
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
error TS7006: Parameter 'items' implicitly has an 'any' type.
Without this flag, items would be any. Then item.prise (a typo) would still compile just fine. The fix is to declare the type explicitly:
interface OrderItem {
name: string;
price: number;
}
function calculateTotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
This flag only blocks implicit any. You can still write any explicitly if you really need to.
3.2. strictNullChecks
This is the flag that’s worth its weight in gold. When off, null and undefined can be assigned to any type. When on, they become distinct types and you must handle them explicitly.
interface Customer {
id: number;
email: string;
}
const customers: Customer[] = [{ id: 1, email: "an@example.com" }];
function getCustomerEmail(id: number): string {
const customer = customers.find((c) => c.id === id);
return customer.email;
}
error TS18048: 'customer' is possibly 'undefined'.
Array.prototype.find returns Customer | undefined. Without the flag, TypeScript ignores the undefined, and the error Cannot read properties of undefined only shows up at runtime. The fix:
function getCustomerEmail(id: number): string | undefined {
const customer = customers.find((c) => c.id === id);
return customer?.email;
}
The function now returns string | undefined. The return type tells the truth, so callers are forced to handle the not-found case.
Note: Don’t “fix” the error using the non-null assertion operator
customer!.email. The!operator just silences the compiler; it doesn’t actually check anything. Only use it when you’re certain the value exists and have a reason the compiler can’t see.
3.3. strictPropertyInitialization
This flag requires that every class property must be assigned a value, either at declaration or in the constructor. It only works when strictNullChecks is also on.
class PaymentService {
apiKey: string;
retries = 3;
constructor(private readonly baseUrl: string) {}
}
error TS2564: Property 'apiKey' has no initializer and is not definitely assigned in the constructor.
You have three ways to handle this:
class PaymentService {
apiKey: string; // 1. assign in constructor
webhookSecret?: string; // 2. declare as optional
client!: HttpClient; // 3. definite assignment: "trust me, it will be assigned"
constructor(apiKey: string) {
this.apiKey = apiKey;
}
}
interface HttpClient {
post(url: string, body: unknown): Promise<unknown>;
}
Approach 3 (!) is common with frameworks that use dependency injection or decorators, like Angular or TypeORM. There, properties are assigned after the constructor runs. Except for those cases, prefer approach 1 or 2.
3.4. useUnknownInCatchVariables
In JavaScript, you can throw anything, not just Error. This flag changes the type of the catch variable from any to unknown.
async function chargeOrder(orderId: string): Promise<void> {
try {
await fetch(`/api/orders/${orderId}/charge`, { method: "POST" });
} catch (err) {
console.error(err.message);
}
}
error TS18046: 'err' is of type 'unknown'.
To use err, you must narrow the type first:
catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Charge failed: ${message}`);
}
3.5. strictFunctionTypes
This flag checks function type parameters contravariantly. In simple terms: you can’t pass a function that only handles a narrow type where a function handling a wider type is needed.
interface Animal { name: string; }
interface Dog extends Animal { bark(): void; }
const handleDog = (dog: Dog) => dog.bark();
const handleAnimal: (animal: Animal) => void = handleDog;
error TS2322: Type '(dog: Dog) => void' is not assignable to type '(animal: Animal) => void'.
If that assignment were accepted, calling handleAnimal({ name: "Cat" }) would crash because the object doesn’t have bark. One exception to know: this flag does not apply to methods declared with the syntax method(x: T): void in interfaces. That’s an intentional choice by TypeScript to maintain compatibility with many existing APIs.
3.6. Three more flags
These three flags rarely cause errors in new code, but still worth knowing:
function formatPrice(amount: number, currency: string): string {
return `${amount.toFixed(2)} ${currency}`;
}
formatPrice.call(null, "100", "VND"); // strictBindCallApply
function onClick() {
console.log(this.id); // noImplicitThis
}
error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
error TS2683: 'this' implicitly has type 'any' because it does not have a type annotation.
strictBindCallApplychecks arguments when you usecall,apply, andbind.noImplicitThisreports an error whenthishas an implicitanytype. You fix it by declaring a dummy parameterfunction onClick(this: HTMLButtonElement).alwaysStrictemits"use strict"in each output file and parses code in JavaScript’s strict mode. With ES modules, strict mode is already the default.
4. Flags that strict doesn’t enable
Many people think strict: true is the most stringent level. It’s not. The following flags are outside the strict group, but are very worth enabling for new projects:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
Of these, noUncheckedIndexedAccess is the most important. By default, TypeScript assumes array indexing or dynamic object key access always returns a value:
const stockBySku: Record<string, number> = { "SKU-1": 10 };
const quantity = stockBySku["SKU-999"];
console.log(quantity.toFixed(0));
Without this flag, quantity has type number and the code above crashes at runtime. With the flag, quantity has type number | undefined:
error TS18048: 'quantity' is possibly 'undefined'.
The other flags:
exactOptionalPropertyTypes:discount?: numbermeans “the key might not exist”, not “you can explicitly assigndiscount: undefined”.noImplicitOverride: overriding methods must have theoverridekeyword. That way, if the parent class renames a method, the child class gets an error instead of silently creating a new method.noImplicitReturns: every code path in a function mustreturn.noFallthroughCasesInSwitch: prevents forgettingbreakin aswitchstatement.
Note:
noUncheckedIndexedAccessis quite “noisy”. Even a loop likefor (let i = 0; i < arr.length; i++)makesarr[i]have typeT | undefined. Switch tofor...oforarr.forEachinstead. For large existing projects, enable this flag last.
5. Check behavior with tests
Type checking runs at compile time, but you should still have tests for both runtime behavior and return types. Vitest supports both:
npm install --save-dev vitest
// customer.ts
export interface Customer { id: number; email: string; }
const customers: Customer[] = [{ id: 1, email: "an@example.com" }];
export function getCustomerEmail(id: number): string | undefined {
return customers.find((c) => c.id === id)?.email;
}
// customer.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { getCustomerEmail } from "./customer";
describe("getCustomerEmail", () => {
it("returns email when customer is found", () => {
expect(getCustomerEmail(1)).toBe("an@example.com");
});
it("returns undefined when not found", () => {
expect(getCustomerEmail(42)).toBeUndefined();
});
it("return type includes undefined", () => {
expectTypeOf(getCustomerEmail).returns.toEqualTypeOf<string | undefined>();
});
});
The third test is a type test. Suppose later someone changes the function signature to : string and uses ! to assert the type. Then vitest --typecheck (or tsc --noEmit on the test file) will report an error, because the return type no longer matches string | undefined.
npx vitest run --typecheck
✓ customer.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)
6. Migrating an old project to strict
Enabling strict: true on a large codebase can generate thousands of errors. The practical approach is to go step by step.
Step 1: enable strict, but temporarily disable the noisiest flags.
{
"compilerOptions": {
"strict": true,
"strictNullChecks": false,
"strictPropertyInitialization": false,
"useUnknownInCatchVariables": false
}
}
noImplicitAny is usually the easiest to fix, so handle it first. After fixing each flag, delete the line that disables it. New code will automatically follow all enabled checks.
Step 2: count errors by flag to estimate effort.
npx tsc --noEmit --strictNullChecks true | grep -c "error TS"
Step 3: with strictNullChecks, fix directory by directory. Temporarily mark places you haven’t fixed yet with // @ts-expect-error, including a comment explaining why. Don’t use @ts-ignore. Reason: @ts-expect-error will itself report an error when the line below no longer has an error, so you won’t miss outdated comments.
// @ts-expect-error TODO(strict): customer could be undefined, handle in PR #482
const email: string = findCustomer(id).email;
Step 4: prevent regressions in CI. Add tsc --noEmit to your pipeline so no one can merge code that introduces new type errors.
Note: Don’t use
skipLibCheckor sprinkleanyeverywhere as a shortcut to “enable strict quickly”.skipLibCheck: trueonly skips checking.d.tsfiles, so it’s fine to keep. But scatteringanyeverywhere will destroy all the benefits of the migration.
7. Conclusion
strict: true enables nine checks. The two checks with the highest payoff are noImplicitAny and strictNullChecks, because they catch most undefined is not a function and Cannot read properties of undefined errors right at compile time. For new projects, enable strict along with noUncheckedIndexedAccess, noImplicitOverride, and exactOptionalPropertyTypes from the start. For existing projects, enable strict, then temporarily disable individual flags, fix them gradually from easiest to hardest, and use CI to ensure error count only decreases, never increases.