---
title: "TypeScript: Strict Mode"
slug: "typescript-strict-mode"
category: "coding"
tags: ["coding", "typescript", "strict", "tsconfig", "types"]
status: "stable"
last_updated: 2026-05-14
summary: "Always enable strict, plus noUncheckedIndexedAccess and exactOptionalPropertyTypes; the migration path from a loose codebase."
related: ["[[coding/typescript]]", "[[coding/typescript-tsconfig]]", "[[coding/typescript-narrowing]]", "[[coding/typescript-utility-types]]", "[[coding/general-principles]]"]
---

## Overview

`strict: true` is the floor for any TypeScript project, not the ceiling. Every flag inside `strict` catches a class of real bug that loose code ships to production. This page is the rule set for what to turn on, why, and how to migrate a loose codebase without freezing feature work. Read [[coding/typescript]] first for the broader playbook.

## Turn on `strict: true` on day one

Set `strict: true` in `tsconfig.json` before you write the second file. Adding it later means fighting hundreds of errors that a fresh project never had to write.

```json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true
  }
}
```

`strict` is an umbrella that enables `noImplicitAny`, `strictNullChecks`, `strictFunctionTypes`, `strictBindCallApply`, `strictPropertyInitialization`, `noImplicitThis`, `useUnknownInCatchVariables`, and `alwaysStrict`. Disabling any individual flag to "make the errors go away" is a vote for an untyped codebase.

## Treat `noUncheckedIndexedAccess` as part of strict

Strict mode misses one truth: array indexing returns `T | undefined`, not `T`. `noUncheckedIndexedAccess` fixes that.

```ts
const items = ["a", "b", "c"]

// Without the flag: items[10] is typed string. It is undefined at runtime.
// With the flag: items[10] is string | undefined, and the compiler enforces a check.
const value = items[10]
if (value !== undefined) {
  console.log(value.toUpperCase())
}
```

This flag is the single biggest source of "we caught a production bug at the type level" wins. Turn it on. See [[coding/typescript-narrowing]] for the narrowing patterns that follow.

## Use `exactOptionalPropertyTypes` for honest optionals

By default, an optional property `paidAt?: Date` accepts both "missing" and "explicitly undefined." That is a lie; most code treats them differently. `exactOptionalPropertyTypes: true` forces you to model the distinction.

```ts
type Invoice = { id: string; paidAt?: Date }

// With the flag on:
const a: Invoice = { id: "1" }                  // OK: paidAt missing
const b: Invoice = { id: "1", paidAt: undefined } // Error: undefined is not Date

// If undefined is a real value, model it:
type Invoice2 = { id: string; paidAt?: Date | undefined }
```

The flag forces you to decide whether `undefined` is a sentinel or an oversight. Pick one.

## Use `noImplicitOverride` for inheritance hygiene

When a subclass redefines a method, write `override` on the redefinition. The compiler checks that the parent actually has a method by that name; rename refactors stop silently breaking subclasses.

```ts
class Base {
  greet() { return "hi" }
}

class Child extends Base {
  override greet() { return "hello" }  // typo in the parent name now fails to compile
}
```

This costs nothing and prevents the silent-shadow bug that bites every long-lived class hierarchy. See [[coding/general-principles]] for the wider rules on inheritance and composition.

## Treat `catch` variables as `unknown`

`useUnknownInCatchVariables` (part of `strict`) types caught errors as `unknown`. Narrow before use.

```ts
try {
  await loadInvoice(id)
} catch (err) {
  if (err instanceof Error) {
    logger.error({ message: err.message, stack: err.stack })
    return
  }
  logger.error({ message: String(err) })
}
```

The old `any` behavior let `err.statusCode` compile when `err` was a string. The new behavior makes you check. See [[coding/typescript-narrowing]] for the `instanceof` and `in` patterns.

## Migrate a loose codebase incrementally

Flipping `strict: true` on a million-line codebase produces ten thousand errors and a frozen team. The migration path:

1. Set `strict: true` and `noUncheckedIndexedAccess: true` on a new `tsconfig.strict.json` that `extends` the main config and lists only the files that already pass.
2. As you touch each file, fix the errors and move it from the loose list to the strict list.
3. Use `// @ts-expect-error` (never `@ts-ignore`) for legitimate temporary holes; the compiler will flag the comment once the error goes away.
4. When the strict list covers the whole codebase, delete the loose config.

Track migration progress with a count of files in the strict list. Avoid the temptation to ship `strict: false` with hand-written `as any` casts; that produces an untyped codebase that pretends to be typed.

## Audit `any` and `as` casts in code review

Strict mode is undone by one `as any` per file. Treat `any`, `as unknown as T`, and `// @ts-ignore` as code-review smells. Each one needs a comment explaining why it is the right escape hatch.

```ts
// Bad
const config = JSON.parse(raw) as Config

// Better: validate at the boundary.
const config = ConfigSchema.parse(JSON.parse(raw))
```

See [[coding/typescript-runtime-validation]] for the schema-at-the-boundary rule that removes most casts. For the umbrella rules, see [[coding/typescript]].

## Related

- [[coding/typescript]]
- [[coding/typescript-tsconfig]]
- [[coding/typescript-narrowing]]
- [[coding/typescript-utility-types]]
- [[coding/general-principles]]
