Vai al contenuto

Stack · TypeScript strict

TypeScript strict è una disciplina

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, prima che ci arrivi un ticket.

Perché questo stack

Strict è una famiglia di 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 a ogni boundary, validato a runtime

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 girano in CI

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

Raccontaci il tuo codebase TypeScript

Nuovi progetti strict e migrazione a strict entrambi benvenuti.