La mayoría de los consejos sobre "TypeScript strict" se queda en activar strict: true en tsconfig.json y dar el día por bueno. El modo strict en producción se parece al fetcher de facturas de abajo: la respuesta de red llega como unknown, Zod la valida, el resultado se estrecha en una discriminated union, y quien llama hace switch exhaustivo sobre la union. Sin any, sin as User, sin data!.invoice non-null assertion.
1. El schema es la fuente de verdad
Un schema Zod define la forma. El tipo TypeScript se infiere desde él; el check en runtime usa la misma definición. Añadir un campo significa editar un solo sitio.
// 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. El fetcher devuelve una discriminated union
El tipo de retorno es una union de Success | NotFound | InvalidResponse. Quien llama no puede tratar accidentalmente el caso de fallo como success porque TypeScript estrecha por 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. Quien llama hace switch exhaustivo
Quien llama gestiona cada caso de la union. La assertion never al final es un check compile-time: si alguien añade un nuevo tag a la union y se olvida de manejarlo aquí, la build falla.
// 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. La config ESLint que sostiene la línea
La config de lint desactiva las tres vías de escape hacia las que el codebase deriva por defecto. La CI ejecuta tanto tsc --noEmit como eslint .; el fallo de uno cualquiera bloquea el merge.
// eslint.config.js (extracto)
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. Qué te compra esto
Una columna de la base de datos renombrada rompe la build, no un ticket de customer support. Un nuevo tipo de evento desde un webhook de terceros es un error de TypeScript, no un fall-through silencioso. Un cambio al schema del formulario fluye automáticamente al tipo de Server Action, al tipo de la base de datos y al tipo de las props de UI. El compilador pasa de molestia a colega que revisa cada cambio antes de que lo vea el equipo.
TypeScript strict no es sintaxis más severa; son tipos honestos. El codebase pasa a ser la documentación, y la documentación nunca se queda obsoleta porque la build fallaría si lo hiciera.