TypeScript strict is a discipline, not a config flag
The strict family of flags on, Zod at every boundary, types generated from the database and the API, no any and no as casts in source. Code that catches the renamed column at build time, not at the customer support ticket.
Why this stack
Strict is a family, not a single flag
`strict: true` is the floor, not the ceiling. `noUncheckedIndexedAccess` makes array access return `T | undefined` (the truth). `exactOptionalPropertyTypes` stops treating `undefined` and missing as the same thing. `noImplicitOverride` makes inheritance honest. We ship every project with the full family on.
Zod at the boundary, not type assertions
A type assertion is a lie. `as User` does not check that the value is a `User`; it tells the compiler to trust you. We use Zod schemas at every system boundary (form submit, API response, database read, environment variables) and infer the TypeScript type from the schema. Runtime check and compile-time type are the same shape.
Generated types replace hand-written interfaces
The database has types, the OpenAPI spec has types, the MDX frontmatter has types. We generate the TypeScript from the source of truth and commit the generated file. The CI fails if the file drifts. One source of truth per system, never two.
No any, no as, no non-null assertion
Three escape hatches the compiler offers; three escape hatches we disable in ESLint. `any` becomes `unknown` plus a type narrowing step. `as` becomes a Zod parse. `!` becomes a real null check. The codebase says what it means.
Type checks in the contract, not in someone's editor
A pre-commit hook runs `tsc --noEmit`. The CI runs it on every PR. The build fails on type errors before deploy. A type error is a P0 in the contract; we do not merge around it with a TODO comment.
What we build with it
tsconfig with the full strict family
`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `noImplicitReturns`, `noUnusedLocals`, `noUnusedParameters`.
Zod schemas at every boundary
Form input, API response, database read, environment variables, MDX frontmatter, third-party webhooks. The schema is the source of truth; the TypeScript type is inferred.
Generated database types
`supabase gen types typescript --linked` runs on every migration, the output is committed, CI fails on drift.
Generated API types
OpenAPI specs imported with `openapi-typescript` for third-party APIs that publish them. Manual schemas for the ones that do not, with a versioned changelog.
TypeScript-strict React patterns
Server Action input and output types, Client Component prop types, error boundary state types, all derived from Zod schemas.
Discriminated unions for state machines
Loading / success / error states modelled as discriminated unions, with `never` checks for exhaustiveness.
Type-safe routing
Next.js route params typed via the route literal, locale union, slug union, query string parsed by Zod.
Migration from loose TypeScript
Codebase upgraded to strict family incrementally, one rule at a time, with type errors fixed in their own commits so review stays sane.
ESLint config for strict patterns
`no-explicit-any`, `no-non-null-assertion`, `consistent-type-imports`, `consistent-type-definitions`, `no-misused-promises`, `restrict-template-expressions`.
Pre-commit type-check
A husky hook runs `tsc --noEmit` before each commit. Type errors block the commit, not a code review comment.
CI type-check on every PR
GitHub Actions runs `tsc --noEmit && eslint .` on every PR. The build status gate blocks merging on red.
Documentation generated from types
API references, prop tables, frontmatter shapes — extracted from the TypeScript itself with TypeDoc or a custom generator.
A discriminated union, Zod at the boundary, no any in source
An invoice fetcher that reads an unknown payload, validates with Zod, narrows into a discriminated union, and returns a typed result the caller can switch on. No any, no as, no non-null assertion.
Most "TypeScript strict" advice stops at flipping strict: true in tsconfig.json and calling it a day. Production strict mode looks like the invoice fetcher below: the network response arrives as unknown, Zod validates it, the result narrows into a discriminated union, and the caller switches on the union exhaustively. No any, no as User, no data!.invoice non-null assertion.
1. The schema is the source of truth
A Zod schema defines the shape. The TypeScript type is inferred from it; the runtime check uses the same definition. Adding a field means editing one place.
// src/lib/billing/schemas.ts
import { z } from 'zod'
export const InvoiceStatus = z.enum([
'draft',
'open',
'paid',
'void',
'uncollectible',
])
export const Invoice = z.object({
id: z.string().uuid(),
number: z.string(),
status: InvoiceStatus,
amountCents: z.number().int().nonnegative(),
currency: z.string().length(3),
customerEmail: z.string().email(),
createdAt: z.string().datetime(),
})
export type Invoice = z.infer<typeof Invoice>
2. The fetcher returns a discriminated union
The return type is a union of Success | NotFound | InvalidResponse. The caller cannot accidentally treat the failure case as success because TypeScript narrows by tag.
// src/lib/billing/fetcher.ts
import { z } from 'zod'
import { Invoice } from './schemas'
const NotFound = z.object({
status: z.literal(404),
})
export type FetchInvoiceResult =
| { tag: 'success'; invoice: Invoice }
| { tag: 'not_found' }
| { tag: 'invalid_response'; reason: string }
export async function fetchInvoice(id: string): Promise<FetchInvoiceResult> {
const response = await fetch(`/api/invoices/${id}`)
if (response.status === 404) {
return { tag: 'not_found' }
}
const raw: unknown = await response.json()
const parsed = Invoice.safeParse(raw)
if (!parsed.success) {
return {
tag: 'invalid_response',
reason: parsed.error.message,
}
}
return { tag: 'success', invoice: parsed.data }
}
3. The caller switches exhaustively
The caller handles every case of the union. The never assertion at the end is a compile-time check: if someone adds a new tag to the union and forgets to handle it here, the build fails.
// app/[lang]/invoices/[id]/page.tsx
import { notFound } from 'next/navigation'
import { fetchInvoice } from '@/lib/billing/fetcher'
import { InvoiceView } from './InvoiceView'
interface Props {
params: Promise<{ id: string }>
}
export default async function InvoicePage({ params }: Props) {
const { id } = await params
const result = await fetchInvoice(id)
switch (result.tag) {
case 'success':
return <InvoiceView invoice={result.invoice} />
case 'not_found':
notFound()
case 'invalid_response':
return <InvalidResponseError reason={result.reason} />
default:
return assertNever(result)
}
}
function InvalidResponseError({ reason }: { reason: string }) {
return (
<section className="ds-section">
<h1>This invoice could not be displayed.</h1>
<p>The server returned a payload we did not recognise.</p>
<details>
<summary>Technical detail</summary>
<pre>{reason}</pre>
</details>
</section>
)
}
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}
4. The ESLint config that holds the line
The lint config disables the three escape hatches the codebase otherwise drifts toward. The CI runs both tsc --noEmit and eslint .; either failing blocks the merge.
// eslint.config.js (excerpt)
export default [
{
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports' },
],
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/restrict-template-expressions': [
'error',
{ allowNumber: true, allowBoolean: false, allowNullish: false },
],
},
},
]
5. What this buys you
A renamed database column breaks the build, not a customer support ticket. A new event type from a third-party webhook is a TypeScript error, not a silent fall-through. A change to the form schema flows automatically into the Server Action type, the database type and the UI prop type. The compiler turns from a nuisance into a colleague who reviews every change before the team sees it.
Strict TypeScript is not stricter syntax; it is honest types. The codebase becomes the documentation, and the documentation never goes stale because the build would fail if it did.
Frequently asked questions
Is `strict: true` enough?
It is the floor. We turn on the whole strict family because the extra flags catch real bugs we have seen in production: `noUncheckedIndexedAccess` for the array-out-of-bounds, `exactOptionalPropertyTypes` for the optional-vs-missing confusion, `noImplicitOverride` for the silent shadowed method.
Doesn't `noUncheckedIndexedAccess` add noise everywhere?
A little, in places that genuinely need to handle the missing case. Most of the noise is a real bug catching itself: code that assumed `arr[0]` exists when it might not. We add explicit null checks (or `arr.at(0)`) and the noise becomes the documentation.
How long does the migration from loose to strict take?
Depends on the codebase size and how many `any` shortcuts are already in. A medium codebase is one to three days to flip the strict family on; the bigger work is replacing `as` assertions with Zod and `any` with `unknown` plus narrowing. We migrate in commits per rule so review stays sane.
any versus unknown — what is the rule?
`any` is opt-out from type checking; `unknown` is acknowledge the type is unknown and prove it before using. We use `unknown` everywhere `any` would be the easy out, and we prove the shape with Zod or a discriminated-union narrowing.
When are `as` casts allowed?
Almost never in source code. Allowed in test fixtures, in code generated from external schemas where the generator emits looser types than we want, and in framework escape hatches where the framework itself has no better signature. Each allowed case is documented in the ESLint config.
Does strict mode hurt build performance?
No. The strict family is mostly about catching more issues during the type check; the check itself is no slower than the same check without the flags. Incremental builds are unchanged. Build performance complaints almost always trace to a different problem.
How does the generated-types workflow work in practice?
An npm script regenerates the file (database, OpenAPI, MDX frontmatter). The generated file is committed. CI re-runs the same script and fails if the output differs from the committed copy. Two-step protection: nobody can deploy stale types and nobody can hand-edit the generated file.
Tell us about your TypeScript codebase
A scoping call, a concrete number in the first reply, no agency theater. Greenfield strict builds and strict-migration both welcome.