Sébastien TIMONER
Esperto in sviluppo web e gestione di team tecnici, mi specializzo nella creazione e ottimizzazione di soluzioni digitali performanti. Grazie a una profonda padronanza di tecnologie moderne come React.js, Node.js, TypeScript, Symfony, Docker e FrankenPHP, garantisco il successo di progetti SaaS complessi, dalla progettazione alla messa in produzione, per aziende di diversi settori.
I TypeScript Utility Types sono tipi generici predefiniti che facilitano le trasformazioni di tipo. Permettono di creare nuovi tipi a partire da quelli esistenti, ad esempio rendendo alcune proprietà opzionali o estraendo il tipo di ritorno di una funzione. Questa guida educativa, in tre livelli (principiante, intermedio, avanzato), presenta tutti i principali Utility Types di TypeScript (come Partial, Required, Readonly, Pick, Omit, Record, Exclude, Extract, NonNullable, Parameters, ReturnType, InstanceType, ThisType, ecc.), con per ciascuno: una spiegazione della sua utilità, un esempio d'uso in TypeScript, quindi un confronto con un approccio equivalente in Zod, una libreria di validazione a runtime. Discuteremo anche le differenze filosofiche tra gli Utility Types di TypeScript (sistema di tipi statico) e gli schemi Zod (validazione a runtime).
Per provare gli esempi di codice, puoi inizializzare un progetto TypeScript minimale usando Bun (un runner JavaScript ultra-veloce che funge anche da gestore di pacchetti). Assicurati di avere Bun installato, poi in una cartella vuota esegui:
shell
Bun creerà un package.json e un tsconfig.json di default. Puoi poi eseguire un file TypeScript con bun run file.ts (Bun transpila TypeScript al volo).
Siamo pronti per esplorare gli Utility Types!
TypeScript: Il tipo utility Partial<T> costruisce un tipo a partire da un tipo esistente T rendendo tutte le sue proprietà opzionali. In altre parole, ogni proprietà di T diventa opzionale (aggiungendo ?). Questo ti permette di rappresentare oggetti parziali di questo tipo. Ad esempio, se hai un tipo User con campi obbligatori, Partial<User> ti permette di creare una versione in cui questi campi possono essere omessi:
typescript
Qui, Partial<User> ha il tipo { name?: string; age?: number }. Questo ti permette, ad esempio, di implementare una funzione di aggiornamento parziale senza richiedere tutti i campi:
typescript
Puoi chiamare updateUser(existingUser, { age: 26 }) con un oggetto parziale – TypeScript garantisce staticamente che fieldsToUpdate contenga solo chiavi valide di User.
Equivalente con Zod: Zod offre un metodo equivalente per rendere uno schema oggetto parziale. Il metodo .partial() applicato a uno schema z.object rende tutte le chiavi opzionali, simile a Partial in TypeScript. Ad esempio:
typescript
Qui PartialUserSchema è uno schema che valida oggetti che possono contenere solo un sottoinsieme di chiavi (name e age diventano opzionali). Puoi verificarlo così:
typescript
Zod permette anche di rendere opzionali solo alcune proprietà passando un oggetto come parametro (UserSchema.partial({ age: true }) rende opzionale solo age). Nota che .partial() agisce solo sul primo livello; Zod offre anche .deepPartial() per rendere opzionali ricorsivamente i campi annidati.
TypeScript: Il tipo utility Required<T> è l'opposto di Partial: crea un tipo a partire da T rendendo tutte le sue proprietà obbligatorie (non opzionali). È utile per "solidificare" un tipo le cui proprietà erano opzionali. Ad esempio:
typescript
In questo esempio, Required<Settings> è equivalente a { theme: string; fontSize: number } – l'omissione di una proprietà diventa un errore di compilazione.
Equivalente con Zod: Per uno schema Zod, l'equivalente è il metodo .required(), che trasforma uno schema oggetto rendendo tutte le proprietà obbligatorie (a differenza di .partial() che le rende opzionali). Questo metodo è particolarmente utile se prima hai definito uno schema parziale e vuoi ottenere la versione stretta. Ad esempio:
typescript
Qui StrictUserSchema richiede che name e age siano presenti. Puoi anche specificare solo alcune proprietà: BaseUserSchema.required({ age: true }) rende obbligatorio solo age (e lascia name opzionale).
Nota che Required<T> agisce a compile time (non esiste più nel codice JavaScript emesso), mentre schema.required() di Zod agisce a runtime validando la presenza delle chiavi durante il parsing.
TypeScript: Il tipo utility Readonly<T> trasforma un tipo T rendendo le sue proprietà di sola lettura (immutabili). Qualsiasi tentativo di riassegnare queste proprietà sarà segnalato come errore in compilazione. Ad esempio:
typescript
Qui, todo è di tipo Readonly<Todo> quindi i suoi campi non possono essere modificati dopo l'inizializzazione. Questo spesso corrisponde all'uso di Object.freeze in JavaScript, ma TypeScript lo gestisce solo a livello di tipo (non impedisce realmente la mutazione a runtime, avvisa solo a compile time).
Equivalente con Zod: Zod offre una funzionalità interessante per l'immutabilità. Il metodo .readonly() applicato a uno schema restituisce un nuovo schema che, durante il parsing, chiama Object.freeze() sul risultato, e il cui tipo statico è contrassegnato come Readonly<...>. In altre parole, Zod può congelare l'oggetto validato e garantire che corrisponda a un tipo readonly. Ad esempio:
typescript
Qui, FrozenUserSchema congela l'oggetto restituito da parse. Se provi a modificare frozenUser, verrà lanciata un'eccezione a runtime perché l'oggetto è immutabile. Quindi, Zod va oltre TypeScript: dove Readonly<T> è puramente statico (le proprietà potrebbero essere modificate in JS se si aggira il typing), lo schema .readonly() di Zod garantisce una reale immutabilità a runtime oltre a fornire il corrispondente tipo Readonly<...>.
TypeScript: Pick<T, Keys> costruisce un nuovo tipo da T mantenendo solo alcune proprietà specificate da Keys (tipicamente una union di string literal che rappresentano i nomi delle proprietà). È utile per creare un tipo più ristretto da uno esistente. Ad esempio:
typescript
Qui, TodoPreview mantiene solo title e completed da FullTodo. Qualsiasi proprietà non elencata (ad esempio description) è esclusa dal tipo risultante.
Equivalente con Zod: Gli schemi oggetto di Zod hanno anche un metodo .pick() che crea un nuovo schema contenente solo le chiavi indicate, in modo simile:
typescript
Passi un oggetto con le chiavi da mantenere impostate su true. TodoPreviewSchema validerà solo oggetti con title e completed (le altre proprietà di FullTodo saranno ignorate dal parser di default). Ad esempio, TodoPreviewSchema.parse({ title: "Clean", completed: false, description: "..." }) restituirà { title: "...", completed: false } avendo rimosso description (perché di default Zod ignora le chiavi non specificate, comportamento che può essere regolato con .strict() o .passthrough() se necessario).
Allo stesso modo, Zod fornisce .omit() per l'effetto opposto.
TypeScript: Omit<T, Keys> crea un nuovo tipo a partire da T rimuovendo alcune proprietà (Keys). È il complemento di Pick (in effetti Omit<T, K> è spesso implementato come Pick<T, Exclude<keyof T, K>>). Esempio:
typescript
Qui TaskMeta contiene solo id e title. Le proprietà done e dueDate sono state escluse.
Equivalente con Zod: Il metodo .omit() di Zod produce uno schema oggetto senza le chiavi indicate. Esempio:
typescript
TaskMetaSchema validerà oggetti contenenti solo id e title. Qualsiasi chiave omessa (done, dueDate) sarà ignorata se presente nell'input. Pick e Omit ti permettono di proiettare uno schema Zod su un sottoinsieme di campi proprio come i loro equivalenti TypeScript a livello di tipo.
TypeScript: Record<Keys, Type> costruisce un tipo oggetto le cui chiavi sono di tipo Keys (tipicamente una union di string literal o un tipo string/number più generale) e i valori sono di tipo Type. Questo ti permette di descrivere, ad esempio, un oggetto indicizzato da identificatori. Un classico esempio:
typescript
Qui, Record<CatName, CatInfo> assicura che l'oggetto cats abbia esattamente le chiavi "miffy", "boris", "mordred", ciascuna associata a un CatInfo. Record è molto utile per tipizzare oggetti usati come dizionari o mappe.
Equivalente con Zod: Zod offre lo schema z.record(keySchema, valueSchema) per validare oggetti di questo tipo. Puoi usarlo con uno schema chiave (ad esempio z.string() o uno schema literal) e uno schema valore. Esempio:
typescript
Qui, AgeMapSchema valida qualsiasi chiave purché sia una stringa (di default, z.record senza primo argomento assume string). Se hai un insieme finito di chiavi, spesso è meglio usare z.object({ ... }) con chiavi esplicite o z.enum([...]) per limitare le chiavi. In effetti, Zod permette uno schema chiave più restrittivo: puoi definire const CatNameSchema = z.enum(["miffy","boris","mordred"]) e poi fare z.record(CatNameSchema, CatInfoSchema) – tuttavia, a runtime le chiavi degli oggetti JavaScript sono sempre stringhe. Anche una chiave numerica sarà convertita in stringa a runtime (ad esempio, una chiave 1 diventa "1" in un oggetto JS). Zod tiene conto di questo e non permette di definire uno schema chiave puramente numerico senza trasformarlo in stringa.
In sintesi, Record<Keys, Type> (TypeScript) e z.record(keySchema, valueSchema) (Zod) ti permettono entrambi di tipizzare dizionari. Il primo agisce a livello di sistema di tipi, il secondo a runtime per validare la struttura dell'oggetto.
TypeScript: Exclude<U, E> crea un tipo rimuovendo dalla union U tutti i membri assegnabili al tipo E. In altre parole, escludi certi tipi da una union. Ad esempio:
typescript
Qui, Exclude<Role, "admin"> rimuove il literal "admin" dalla union, lasciando solo "user" | "guest". Un altro esempio: Exclude<string | number | boolean, string> darebbe number | boolean (rimuovendo tutto ciò che è assegnabile a string). È utile per affinare una union eliminando i casi indesiderati.
Equivalente con Zod: Per restringere le possibilità di una union a runtime, in genere definisci uno schema che non includa i casi da escludere. Ad esempio, se hai RoleSchema = z.enum(["admin","user","guest"]) puoi creare un sottoinsieme non includendo "admin". Zod fornisce per le enumerazioni il metodo .exclude([...]) che genera un nuovo schema enum senza i valori specificati. Ad esempio:
typescript
Questo NonAdminSchema corrisponde esattamente al tipo Exclude<Role, "admin"> ma garantisce anche la validazione a runtime. Internamente, RoleSchema.exclude(["admin"]) rimuove semplicemente "admin" dalla lista dei literal consentiti. Allo stesso modo, Zod offre .extract([...]) per l'operazione complementare.
TypeScript: Extract<T, U> crea un tipo mantenendo da T solo i membri assegnabili a U. È grosso modo l'opposto di Exclude. Ad esempio:
typescript
Un altro esempio: Extract<"a" | "b" | "c", "a" | "f"> darà "a" (perché solo "a" è comune a entrambi). Questo tipo utility viene quindi usato per filtrare una union ed estrarre solo un certo sottotipo.
Equivalente con Zod: Come per Exclude, Zod permette di ottenere a runtime uno schema corrispondente a un sottoinsieme di un'enumerazione tramite .extract([...]). Prendiamo di nuovo l'esempio dei ruoli: per estrarre solo, ad esempio, i ruoli limitati "user" e "guest" da un RoleSchema completo:
typescript
Qui LimitedRolesSchema corrisponde al tipo Extract<Role, "user" | "guest">. In pratica, potresti ottenere lo stesso risultato definendo direttamente z.enum(["user","guest"]), ma .extract è comodo per derivare uno schema da un enum esistente. Nota che .exclude e .extract di Zod si applicano agli schemi enum (z.enum o z.nativeEnum), e non a union arbitrarie di tipi complessi. Per questi ultimi, in genere costruisci manualmente il nuovo schema union desiderato.
TypeScript: NonNullable<T> costruisce un tipo escludendo null e undefined da T. È comune in TypeScript avere tipi union che includono questi valori "nullish" e voler un tipo che ne sia privo. Ad esempio:
typescript
Qui, DefinitelyString contiene solo string (né null né undefined). Questo tipo utility è spesso usato in combinazione con condizioni di presenza (if (value != null) { ... }) per aiutare il compilatore a raffinare i tipi.
Equivalente con Zod: Con Zod, di default gli schemi non accettano undefined o null a meno che tu non lo specifichi. Ad esempio, uno schema z.string() accetta solo una stringa, non undefined. Per permettere questi valori, usi esplicitamente z.string().optional() (che permette undefined) o z.string().nullable() (che permette null) o anche .nullish() (che permette entrambi). Quindi, ottenere l'equivalente di un tipo NonNullable significa semplicemente non includere null/undefined nello schema. Tuttavia, se parti da uno schema più permissivo e vuoi renderlo più restrittivo, puoi usare una combinazione di tecniche: ad esempio, il metodo .unwrap() permette di estrarre lo schema sottostante da uno schema opzionale o nullable.
typescript
Qui, OptionalName.unwrap() restituisce lo schema z.string() originale. Questo ti permette di "rimuovere" il carattere opzionale o nullable introdotto in precedenza. Un altro approccio sarebbe usare una validazione personalizzata per rifiutare null/undefined, ma sarebbe ridondante dato il comportamento degli schemi Zod.
TypeScript: Parameters<T> estrae i tipi dei parametri di un tipo funzione T come tupla. È utile per lavorare con le firme delle funzioni, specialmente quando vuoi creare una funzione che avvolge un'altra funzione. Ad esempio:
typescript
Qui, Parameters<typeof greet> estrae la tupla [string, number] che rappresenta i parametri di greet. Questo permette a safeGreet di accettare esattamente gli stessi parametri di greet.
Equivalente con Zod: Zod fornisce z.function() per definire schemi di funzione, con metodi per specificare i tipi dei parametri e il tipo di ritorno. Anche se non c'è un equivalente diretto di Parameters<T>, puoi definire uno schema funzione ed estrarre i tipi dei parametri usando Parameters di TypeScript sul tipo inferito:
typescript
Qui, GreetSchema definisce uno schema funzione che accetta una stringa e un numero e restituisce una stringa. Il metodo .implement() crea una funzione wrapper che valida input e output secondo lo schema. Possiamo estrarre i tipi dei parametri usando Parameters<GreetFn>. Questo approccio combina validazione a runtime con tipizzazione statica.
TypeScript: ReturnType<T> estrae il tipo di ritorno di un tipo funzione T. È utile quando vuoi lavorare con il risultato di una funzione senza dover ridefinire esplicitamente il tipo. Ad esempio:
typescript
Qui, ReturnType<typeof createUser> estrae il tipo oggetto restituito da createUser. Questo ci permette di definire il tipo User senza duplicare la struttura.
Equivalente con Zod: Come per Parameters, non c'è un equivalente diretto in Zod per ReturnType, ma puoi combinare lo schema funzione di Zod con ReturnType di TypeScript:
typescript
Qui, definiamo uno schema funzione con CreateUserSchema, poi estraiamo il tipo di ritorno in due modi: usando ReturnType di TypeScript sul tipo funzione inferito, o usando il metodo .returnType() di Zod che restituisce lo schema per il valore di ritorno. Entrambi gli approcci ci danno il tipo User.
TypeScript: InstanceType<T> estrae il tipo istanza di un tipo costruttore T. È utile quando hai un riferimento a una classe e vuoi lavorare con le istanze di quella classe. Ad esempio:
typescript
Qui, InstanceType<typeof Point> ci dà il tipo di un'istanza Point, che include le sue proprietà e metodi.
Equivalente con Zod: Zod non ha un equivalente diretto per i tipi istanza di classe, poiché si concentra sulla validazione dei dati piuttosto che sul comportamento delle classi. Tuttavia, puoi definire uno schema per la forma delle istanze di classe:
typescript
Questo approccio definisce uno schema che corrisponde alla forma di un'istanza Point, ma non cattura la relazione completa della classe. Per una validazione di classe più complessa, potresti dover combinare Zod con class-validator o librerie simili.
I TypeScript Utility Types e gli schemi Zod servono entrambi a garantire l'affidabilità dei dati, ma a livelli diversi: uno a compile time, l'altro a runtime.
In sintesi:
Filosoficamente, gli utility types esprimono trasformazioni o vincoli a livello di sistema di tipi (aiutano lo sviluppatore, senza impatto diretto sul codice emesso), mentre Zod fornisce contratti a livello di runtime (permette l'implementazione concreta di validazioni e trasformazioni dei dati in esecuzione). Entrambi gli approcci rispondono a esigenze diverse ma correlate: mantenere la robustezza del codice. Inoltre, Zod è progettato per integrarsi armoniosamente con TypeScript: inferisce automaticamente i tipi statici dai tuoi schemi, evitando la duplicazione delle definizioni. Questo significa che spesso puoi definire uno schema Zod (per la validazione) e usare z.infer per derivare il corrispondente tipo statico TypeScript, invece di definire un'interfaccia TypeScript e uno schema separato – avendo così una sola fonte di verità per i tuoi tipi di dati.
In conclusione, conoscere i TypeScript Utility Types ti permette di sfruttare il sistema di tipi per scrivere codice sicuro ed espressivo, mentre padroneggiare Zod ti dà gli strumenti per far rispettare questi contratti di tipo a runtime. Usati insieme, rafforzano notevolmente l'affidabilità delle tue applicazioni TypeScript: TypeScript ti protegge dagli errori di programmazione, Zod protegge la tua applicazione da dati inaspettati provenienti dall'esterno. Crea i tuoi tipi, valida i tuoi dati e programma con serenità 🎉!