TypeScript strict è una disciplina, non un flag di config
La famiglia strict di flag attiva, Zod a ogni boundary, tipi generati dal database e dall'API, niente any e niente cast as nel sorgente. Codice che intercetta la colonna rinominata in build, non nel ticket di customer support.
Perché questo stack
Strict è una famiglia, non un singolo flag
`strict: true` è il pavimento, non il soffitto. `noUncheckedIndexedAccess` rende l'accesso a un array `T | undefined` (la verità). `exactOptionalPropertyTypes` smette di trattare `undefined` e "mancante" come la stessa cosa. `noImplicitOverride` rende l'eredità onesta. Rilasciamo ogni progetto con tutta la famiglia attiva.
Zod alla boundary, non type assertion
Una type assertion è una bugia. `as User` non verifica che il valore sia uno `User`; dice al compilatore di fidarsi. Usiamo schemi Zod a ogni boundary di sistema (submit di form, response API, lettura DB, variabili d'ambiente) e inferiamo il tipo TypeScript dallo schema. Check runtime e tipo compile-time hanno la stessa forma.
I tipi generati sostituiscono le interface scritte a mano
Il database ha tipi, la spec OpenAPI ha tipi, il frontmatter MDX ha tipi. Generiamo il TypeScript dalla fonte di verità e committiamo il file generato. La CI fallisce se il file diverge. Una sola fonte di verità per sistema, mai due.
Niente any, niente as, niente non-null assertion
Tre vie di fuga che il compilatore offre; tre vie di fuga che disabilitiamo in ESLint. `any` diventa `unknown` più uno step di type narrowing. `as` diventa un parse Zod. `!` diventa un null check vero. Il codebase dice quello che intende.
I type check nel contratto, non nell'editor di qualcuno
Un pre-commit hook esegue `tsc --noEmit`. La CI lo esegue ad ogni PR. La build fallisce sugli errori di tipo prima del deploy. Un errore di tipo è un P0 nel contratto; non lo aggiriamo con un TODO comment nel merge.
Cosa sviluppiamo con questa tecnologia
tsconfig con la famiglia strict completa
`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `noImplicitReturns`, `noUnusedLocals`, `noUnusedParameters`.
Schemi Zod a ogni boundary
Input dei form, response API, lettura DB, variabili d'ambiente, frontmatter MDX, webhook di terzi. Lo schema è la fonte di verità; il tipo TypeScript è inferito.
Tipi del database generati
`supabase gen types typescript --linked` gira a ogni migration, l'output è committato, la CI fallisce sulla divergenza.
Tipi API generati
Spec OpenAPI importate con `openapi-typescript` per le API di terzi che le pubblicano. Schemi manuali per quelle che non lo fanno, con changelog versionato.
Pattern React in modalità strict
Tipi di input e output delle Server Action, tipi delle prop dei Client Component, tipi di stato delle error boundary: tutti derivati dagli schemi Zod.
Discriminated union per state machine
Stati di loading / success / error modellati come discriminated union, con check `never` per esaustività.
Routing type-safe
Parametri di route Next.js tipizzati via literal di route, union di locale, union di slug, query string parsata da Zod.
Migrazione da TypeScript loose
Codebase aggiornato alla famiglia strict in modo incrementale, una regola alla volta, con errori di tipo corretti in commit dedicati così la review resta sostenibile.
Config ESLint per pattern strict
`no-explicit-any`, `no-non-null-assertion`, `consistent-type-imports`, `consistent-type-definitions`, `no-misused-promises`, `restrict-template-expressions`.
Type-check in pre-commit
Un hook husky esegue `tsc --noEmit` prima di ogni commit. Gli errori di tipo bloccano il commit, non un commento in code review.
Type-check in CI ad ogni PR
GitHub Actions esegue `tsc --noEmit && eslint .` ad ogni PR. Il gate dello stato di build blocca il merge sul rosso.
Documentazione generata dai tipi
Riferimenti API, tabelle di prop, forme di frontmatter: estratti dal TypeScript stesso con TypeDoc o un generatore custom.
Una discriminated union, Zod alla boundary, niente any nel sorgente
Un fetcher di fatture che legge un payload sconosciuto, lo valida con Zod, lo restringe in una discriminated union e ritorna un risultato tipizzato su cui chi chiama può fare switch. Niente any, niente as, niente non-null assertion.
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.
Domande frequenti
Basta `strict: true`?
È il pavimento. Accendiamo tutta la famiglia strict perché i flag extra intercettano bug veri che abbiamo visto in produzione: `noUncheckedIndexedAccess` per l'array fuori bound, `exactOptionalPropertyTypes` per la confusione optional-vs-mancante, `noImplicitOverride` per il metodo silenziosamente shadowato.
`noUncheckedIndexedAccess` non aggiunge rumore ovunque?
Un po', dove serve davvero gestire il caso mancante. La maggior parte del rumore è un bug vero che si fa scoprire: codice che assumeva `arr[0]` esistesse quando poteva non esserci. Aggiungiamo null check espliciti (o `arr.at(0)`) e il rumore diventa la documentazione.
Quanto dura la migrazione da loose a strict?
Dipende dalla dimensione del codebase e da quante scorciatoie `any` ci sono già dentro. Un codebase medio è uno-tre giorni per accendere la famiglia strict; il lavoro più grosso è sostituire `as` con Zod e `any` con `unknown` più narrowing. Migriamo in commit per regola così la review resta sostenibile.
any rispetto a unknown — qual è la regola?
`any` è opt-out dal type checking; `unknown` è ammettere che il tipo non è noto e dimostrarlo prima di usarlo. Usiamo `unknown` ovunque `any` sarebbe la via facile, e dimostriamo la forma con Zod o con un narrowing di discriminated union.
Quando sono ammessi i cast `as`?
Quasi mai nel codice sorgente. Ammessi nelle fixture di test, nel codice generato da schemi esterni dove il generator emette tipi più larghi di quanto vogliamo e negli escape hatch di framework dove il framework stesso non ha una firma migliore. Ogni caso ammesso è documentato nella config ESLint.
La modalità strict peggiora le performance di build?
No. La famiglia strict riguarda l'intercettazione di più problemi durante il type check; il check stesso non è più lento dello stesso check senza i flag. Le build incrementali restano identiche. I lamenti sulle performance di build quasi sempre risalgono a un problema diverso.
Come funziona in pratica il workflow dei tipi generati?
Uno script npm rigenera il file (database, OpenAPI, frontmatter MDX). Il file generato è committato. La CI rilancia lo stesso script e fallisce se l'output differisce dalla copia committata. Doppia protezione: nessuno può deployare con tipi stantii e nessuno può editare a mano il file generato.
Raccontaci il tuo codebase TypeScript
Una call di scoping, un numero concreto nella prima risposta, niente recite da agenzia. Nuovi progetti strict e migrazione a strict entrambi benvenuti.