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→unknownplus narrowing.unknownforces the check thatanyskips. Reserveanyfor genuinely unrepresentable third-party shapes, with a comment naming the library.as→ a type guard.asis an assertion that the compiler stops checking. Preferfunction isUser(v: unknown): v is Useror a parse at the boundary.as constis 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.enumemits runtime code, does not narrow structurally, and behaves differently underisolatedModules. Useconst Status = {...} as constandtype 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.tsre-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 aUserIdcannot be passed where anOrgIdbelongs. 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. satisfiesover annotation when you want the literal type preserved and checked.readonlyon 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:
- Turn on the flags in the root
tsconfig.jsonbut add anexclude(or a loosertsconfig.legacy.json) covering the directories that fail. - Fix one directory, remove it from the exclusion, commit. Repeat.
- Add a CI check that the exclusion list only ever shrinks — a diff that adds a path to it fails.
- For lint rules, use ESLint flat-config
overridesper 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 --noEmitpasses with the flags actually enabled — check the resolved config withtsc --showConfig, not the file you edited, sinceextendscan 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
warnto 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.