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.