La maggior parte dei consigli su "TypeScript strict" si ferma ad attivare strict: true in tsconfig.json e chiude lì. La modalità strict in produzione assomiglia al fetcher di fatture qui sotto: la risposta dalla rete arriva come unknown, Zod la valida, il risultato si restringe in una discriminated union e chi chiama fa switch esaustivo sull'union. Niente any, niente as User, niente data!.invoice non-null assertion.
1. Lo schema è la fonte di verità
Uno schema Zod definisce la forma. Il tipo TypeScript è inferito da lui; il check runtime usa la stessa definizione. Aggiungere un campo significa modificare un solo posto.
// 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. Il fetcher ritorna una discriminated union
Il tipo di ritorno è un'union di Success | NotFound | InvalidResponse. Chi chiama non può per errore trattare il caso di fallimento come successo perché TypeScript restringe via 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. Chi chiama fa switch esaustivo
Chi chiama gestisce ogni caso dell'union. L'assertion never alla fine è un check compile-time: se qualcuno aggiunge un nuovo tag all'union e si dimentica di gestirlo qui, la build fallisce.
// 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 che tiene la linea
La config lint disabilita le tre vie di fuga verso cui il codebase deriva naturalmente. La CI esegue sia tsc --noEmit sia eslint .; il fallimento di una delle due blocca il merge.
// eslint.config.js (estratto)
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. Cosa ti compra questo
Una colonna del database rinominata rompe la build, non un ticket di customer support. Un nuovo tipo di evento da un webhook di terzi è un errore TypeScript, non un fall-through silenzioso. Una modifica allo schema del form fluisce automaticamente nel tipo della Server Action, nel tipo del database e nel tipo delle prop UI. Il compilatore smette di essere un fastidio e diventa un collega che fa review di ogni modifica prima che la veda il team.
TypeScript strict non è una sintassi più severa; sono tipi onesti. Il codebase diventa la documentazione, e la documentazione non resta mai stantia perché la build fallirebbe se lo facesse.