---
name: typescript-strict-baseline
description: Establishes strict TypeScript compiler flags, ESLint rules and type discipline, with a per-directory ratchet so an existing loose codebase can tighten without a four-thousand-error big bang. Use when setting up a TypeScript project, tightening tsconfig, deciding how to model a boundary, or reviewing types in a pull request.
license: MIT
compatibility: TypeScript 5.x, any bundler or runtime. Requires permission to run tsc and the project's linter.
metadata:
  category: coding
  version: "1.0.0"
---

# TypeScript Strict Baseline

Types you cannot trust are worse than no types, because they buy confidence without paying for it. The
point of strictness is not purity; it is that a large refactor becomes a compile-and-fix exercise
instead of a hunt through runtime behaviour. Every rule below is here because it turns a class of
production bug into a red squiggle.

## The flags most repos miss

`strict: true` is the floor, not the ceiling. These four are the ones that catch real bugs:

| Flag | What it actually catches |
| --- | --- |
| `noUncheckedIndexedAccess` | `arr[0]` and `record[key]` typed as present when they are not. The single highest-value flag on this list. |
| `exactOptionalPropertyTypes` | `{ a?: string }` silently accepting `undefined` as a written value, so "absent" and "explicitly undefined" stop being the same thing. |
| `verbatimModuleSyntax` | Type-only imports surviving into emitted JS, which breaks bundlers and side-effect ordering. |
| `isolatedModules` | Constructs that a per-file transpiler (esbuild, swc, Bun) cannot compile correctly. |

Also set `noImplicitOverride`, `noFallthroughCasesInSwitch` and `forceConsistentCasingInFileNames`.
Leave `skipLibCheck: true` — checking dependency types costs minutes and finds nothing you can fix.

## Bans, each with its replacement

A ban without a replacement gets ignored. State both.

- **`any` → `unknown` plus narrowing.** `unknown` forces the check that `any` skips. Reserve `any` for
  genuinely unrepresentable third-party shapes, with a comment naming the library.
- **`as` → a type guard.** `as` is an assertion that the compiler stops checking. Prefer
  `function isUser(v: unknown): v is User` or a parse at the boundary. `as const` is a different
  operator and is fine.
- **`!` (non-null) → an early return.** `if (!user) throw new NotFound()` documents the invariant and
  survives a refactor; `user!` silently becomes a runtime crash when the invariant changes.
- **`enum` → const object plus union.** `enum` emits runtime code, does not narrow structurally, and
  behaves differently under `isolatedModules`. Use `const Status = {...} as const` and
  `type Status = typeof Status[keyof typeof Status]`.
- **Default exports → named exports.** Default exports rename freely at the import site, which breaks
  rename refactors and grep.
- **Barrel files → direct paths.** An `index.ts` re-export pulls the whole directory into the module
  graph on any import from it, defeating tree-shaking and creating cycles.

## Modelling rules

- **Brand your identifiers.** `type UserId = string & { readonly __brand: "UserId" }` means a `UserId`
  cannot be passed where an `OrgId` belongs. Do this the day you have two id types.
- **Discriminated unions over optional-field soup.** `{ status: "loading" } | { status: "ready"; data: T }`
  makes the impossible state unrepresentable; `{ loading?: boolean; data?: T }` invites it.
- **Exhaust every switch.** End with `default: { const _x: never = value; throw new Error(...) }` so
  adding a variant becomes a compile error at every site that handles it.
- **`satisfies` over annotation** when you want the literal type preserved *and* checked.
- **`readonly` on arrays and props by default.** Widen only where mutation is the point.

## Where validation lives

Parse at the I/O boundary, then trust the type inside. The boundary is: HTTP handlers and Server
Actions, queue and webhook consumers, `process.env`, filesystem and third-party API responses.

Use zod or valibot at those points only. A schema in the middle of the domain is a sign the boundary
is in the wrong place. Derive the type from the schema (`z.infer`) rather than declaring it twice.

## Adopting this in an existing codebase

A repo-wide `strict: true` that produces four thousand errors gets reverted the same day. Ratchet
instead:

1. Turn on the flags in the root `tsconfig.json` but add an `exclude` (or a looser
   `tsconfig.legacy.json`) covering the directories that fail.
2. Fix one directory, remove it from the exclusion, commit. Repeat.
3. Add a CI check that the exclusion list only ever shrinks — a diff that adds a path to it fails.
4. For lint rules, use ESLint flat-config `overrides` per directory with the same shrinking rule.

The ratchet is what makes this adoptable. Without it you are asking for a week nobody has.

## Verification gate

- [ ] `tsc --noEmit` passes with the flags actually enabled — check the resolved config with
      `tsc --showConfig`, not the file you edited, since `extends` can override you.
- [ ] `grep -rn ": any\|as any\| as unknown as" src/` returns only lines with a justifying comment.
- [ ] No new path was added to the exclusion list.
- [ ] The linter passes, and no rule was downgraded to `warn` to get there.
- [ ] Paste the command output. Do not describe it from memory.

## Reviewing someone else's types

Ask three questions in order: can this value be `undefined` at runtime and does the type say so; is
there a state this type permits that the system cannot actually be in; and if I rename this field,
does the compiler find every use. Most type review is those three questions.
