Funzionalità avanzate di TypeScript
TypeScript è molto più di un semplice superset tipizzato di JavaScript. Oltre ai tipi primitivi e alle interfacce di base, il linguaggio offre un arsenale di strumenti avanzati che permettono di modellare con estrema precisione la forma dei dati, vincolare le relazioni tra tipi e costruire API type-safe ed espressive. Questo articolo esplora in profondità le funzionalità che separano un utilizzo superficiale di TypeScript da una padronanza reale del sistema di tipi.
Tipi condizionali
I tipi condizionali permettono di esprimere logica a livello di tipo, selezionando un tipo o un altro in base a una condizione strutturale. La sintassi ricalca l'operatore ternario di JavaScript, ma opera interamente nel dominio dei tipi.
// Tipo condizionale di base: restituisce un tipo diverso in base alla struttura di T
type IsString<T> = T extends string ? true : false;
type ResultA = IsString<"ciao">; // true
type ResultB = IsString<42>; // false
La vera potenza dei tipi condizionali emerge quando si combinano con i tipi generici per creare trasformazioni sofisticate. Un caso d'uso frequente è l'estrazione di tipi da strutture complesse.
// Estrae il tipo di ritorno di una funzione, o never se T non è una funzione
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
type StringReturn = ReturnOf<() => string>; // string
type NumberReturn = ReturnOf<(x: number) => number>; // number
type NotAFunction = ReturnOf<boolean>; // never
I tipi condizionali sono distributivi quando applicati a tipi unione. Questo significa che il tipo condizionale viene valutato individualmente per ogni membro dell'unione, e i risultati vengono poi combinati in una nuova unione.
// Rimuove null e undefined da un tipo unione
type NonNullish<T> = T extends null | undefined ? never : T;
// Il tipo viene distribuito su ogni membro dell'unione
type CleanedType = NonNullish<string | null | number | undefined>;
// Risultato: string | number
Inferenza con infer
La parola chiave infer consente di dichiarare variabili di tipo all'interno di un ramo condizionale, estraendo parti di un tipo complesso senza doverle specificare manualmente. È uno degli strumenti più potenti per la metaprogrammazione a livello di tipo.
// Estrae il tipo degli elementi di un array
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Numbers = ElementOf<number[]>; // number
type Strings = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | boolean)[]>; // string | boolean
// Estrae il tipo del primo parametro di una funzione
type FirstParameter<T> = T extends (first: infer P, ...rest: any[]) => any ? P : never;
type Param = FirstParameter<(name: string, age: number) => void>; // string
// Estrae il tipo di una Promise, ricorsivamente
type UnwrapPromise<T> = T extends Promise<infer U>
? UnwrapPromise<U>
: T;
type Deep = UnwrapPromise<Promise<Promise<string>>>; // string
Tipi mappati
I tipi mappati permettono di creare nuovi tipi trasformando sistematicamente ogni proprietà di un tipo esistente. Si basano sulla sintassi [K in keyof T] per iterare sulle chiavi e applicare trasformazioni.
// Rende tutte le proprietà opzionali e di sola lettura
type ReadonlyPartial<T> = {
readonly [K in keyof T]?: T[K];
};
interface UserProfile {
name: string;
email: string;
age: number;
}
// Ogni campo diventa opzionale e immutabile
type DraftProfile = ReadonlyPartial<UserProfile>;
I modificatori + e - permettono di aggiungere o rimuovere esplicitamente readonly e ? dalle proprietà durante la mappatura.
// Rimuove sia readonly che il carattere opzionale da tutte le proprietà
type Mutable<T> = {
-readonly [K in keyof T]-?: T[K];
};
interface FrozenConfig {
readonly host?: string;
readonly port?: number;
}
// Tutte le proprietà diventano obbligatorie e modificabili
type EditableConfig = Mutable<FrozenConfig>;
La clausola as nei tipi mappati consente di rinominare le chiavi durante la trasformazione, aprendo possibilità come il filtraggio di proprietà o la creazione di nuovi nomi derivati.
// Crea metodi getter per ogni proprietà
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Settings {
volume: number;
theme: string;
}
// Risultato: { getVolume: () => number; getTheme: () => string }
type SettingsGetters = Getters<Settings>;
// Filtra le proprietà mantenendo solo quelle di tipo stringa
type StringPropertiesOnly<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface MixedData {
label: string;
count: number;
description: string;
active: boolean;
}
// Risultato: { label: string; description: string }
type OnlyStrings = StringPropertiesOnly<MixedData>;
Template literal types
I template literal types estendono i tipi stringa letterali con la possibilità di interpolare altri tipi al loro interno, permettendo di costruire insiemi di stringhe in modo combinatorio e di imporre vincoli stringenti sui formati accettati.
// Definisce i possibili eventi del DOM come combinazioni di prefisso e nome
type DomEventName = `on${Capitalize<"click" | "focus" | "blur">}`;
// Risultato: "onClick" | "onFocus" | "onBlur"
// Tipo per codici colore esadecimali
type HexDigit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
| "a" | "b" | "c" | "d" | "e" | "f";
// Accetta solo stringhe nel formato #XX (versione semplificata)
type ShortHexColor = `#${HexDigit}${HexDigit}`;
// Estrae le parti di un percorso parametrico in stile Express
type ExtractRouteParams<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<`/${Rest}`>
: T extends `${infer _Start}:${infer Param}`
? Param
: never;
// Estrae automaticamente i nomi dei parametri dalla rotta
type UserRouteParams = ExtractRouteParams<"/api/users/:userId/posts/:postId">;
// Risultato: "userId" | "postId"
Tipi ricorsivi
TypeScript supporta la definizione di tipi che fanno riferimento a sé stessi, permettendo di modellare strutture dati intrinsecamente ricorsive come alberi, JSON, o oggetti annidati a profondità arbitraria.
// Rappresentazione type-safe di un valore JSON qualsiasi
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
// Valido: qualsiasi struttura JSON valida è accettata
const sampleConfig: JsonValue = {
database: {
host: "localhost",
port: 5432,
credentials: {
username: "admin",
passwords: ["secret", "backup"]
}
},
features: [true, false, null]
};
// Rende ricorsivamente tutte le proprietà annidate di sola lettura
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K] // Le funzioni non vengono toccate
: DeepReadonly<T[K]> // Gli oggetti vengono resi ricorsivamente immutabili
: T[K]; // I primitivi restano invariati
};
interface NestedState {
user: {
profile: {
name: string;
preferences: {
theme: string;
notifications: boolean;
};
};
};
}
// Ogni livello di annidamento diventa immutabile
type FrozenState = DeepReadonly<NestedState>;
Tipi variadic con le tuple
I tipi variadic sulle tuple, introdotti in TypeScript 4.0, permettono di manipolare tuple a livello di tipo con operazioni come la concatenazione, lo spreading e la trasformazione posizionale degli elementi.
// Concatena due tuple in una sola
type ConcatTuples<A extends readonly unknown[], B extends readonly unknown[]> = [...A, ...B];
type Merged = ConcatTuples<[string, number], [boolean, Date]>;
// Risultato: [string, number, boolean, Date]
// Rimuove il primo elemento di una tupla
type Tail<T extends readonly unknown[]> =
T extends [infer _Head, ...infer Rest] ? Rest : [];
type WithoutFirst = Tail<[string, number, boolean]>; // [number, boolean]
type EmptyCase = Tail<[]>; // []
// Funzione che accetta i parametri di un'altra funzione più un argomento aggiuntivo iniziale
function withContext<TArgs extends unknown[], TReturn>(
fn: (...args: TArgs) => TReturn,
...args: TArgs
): TReturn {
// Invoca la funzione originale con gli argomenti forniti
return fn(...args);
}
function greet(greeting: string, name: string): string {
return `${greeting}, ${name}!`;
}
// I tipi dei parametri vengono inferiti correttamente
const message = withContext(greet, "Buongiorno", "Marco"); // string
Type guard personalizzati
I type guard personalizzati sono funzioni il cui tipo di ritorno usa la sintassi paramName is Type. Permettono di incapsulare logica di validazione complessa e di comunicare al compilatore il risultato del restringimento di tipo.
interface Cat {
kind: "cat";
purr(): void;
}
interface Dog {
kind: "dog";
bark(): void;
}
type Animal = Cat | Dog;
// Type guard personalizzato: se restituisce true, TypeScript sa che animal è Cat
function isCat(animal: Animal): animal is Cat {
return animal.kind === "cat";
}
function interactWith(animal: Animal): void {
if (isCat(animal)) {
// TypeScript sa che qui animal è Cat
animal.purr();
} else {
// E qui sa che è Dog
animal.bark();
}
}
// Type guard con asserzione: lancia un errore se la condizione non è soddisfatta
function assertDefined<T>(
value: T | null | undefined,
label: string
): asserts value is T {
if (value === null || value === undefined) {
throw new Error(`Il valore "${label}" è ${value}`);
}
}
function processOrder(orderId: string | null): void {
// Dopo questa riga, TypeScript sa che orderId è string
assertDefined(orderId, "orderId");
// Nessun errore: orderId è garantito essere string
console.log(orderId.toUpperCase());
}
Discriminated union
Le discriminated union combinano tipi unione con una proprietà letterale comune (il "discriminante") che TypeScript usa per restringere automaticamente il tipo all'interno di blocchi condizionali. Sono il pattern fondamentale per modellare stati mutualmente esclusivi in modo type-safe.
// Ogni variante ha un campo "status" con un valore letterale diverso
interface LoadingState {
status: "loading";
}
interface SuccessState<T> {
status: "success";
data: T;
}
interface ErrorState {
status: "error";
error: Error;
retryCount: number;
}
type RequestState<T> = LoadingState | SuccessState<T> | ErrorState;
function handleRequest<T>(state: RequestState<T>): string {
switch (state.status) {
case "loading":
// TypeScript sa che qui state è LoadingState
return "Caricamento in corso...";
case "success":
// Qui state è SuccessState<T>, quindi state.data è accessibile
return `Dati ricevuti: ${JSON.stringify(state.data)}`;
case "error":
// Qui state è ErrorState, con error e retryCount disponibili
return `Errore: ${state.error.message} (tentativi: ${state.retryCount})`;
}
}
Tipi nominali simulati con i brand
TypeScript usa un sistema di tipi strutturale: due tipi con la stessa forma sono intercambiabili. In alcuni casi, però, si vuole impedire questa intercambiabilità pur avendo la stessa struttura sottostante. La tecnica dei branded types aggiunge un "marchio" fantasma che rende i tipi nominalmente distinti.
// Crea un tipo branded aggiungendo una proprietà fantasma unica
type Brand<T, TBrand extends string> = T & { readonly __brand: TBrand };
// Due tipi con la stessa struttura base (string) ma incompatibili tra loro
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
// Funzioni di costruzione che validano e marcano i valori
function createUserId(raw: string): UserId {
if (raw.length === 0) throw new Error("UserId non può essere vuoto");
return raw as UserId;
}
function createOrderId(raw: string): OrderId {
if (!raw.startsWith("ORD-")) throw new Error("OrderId deve iniziare con ORD-");
return raw as OrderId;
}
function fetchUser(id: UserId): void {
console.log(`Recupero utente: ${id}`);
}
const userId = createUserId("u-123");
const orderId = createOrderId("ORD-456");
fetchUser(userId); // Corretto
// fetchUser(orderId); // Errore di compilazione: OrderId non è assegnabile a UserId
Sovraccarico delle funzioni
Il sovraccarico delle funzioni permette di definire più firme per la stessa funzione, ciascuna con tipi di parametri e di ritorno diversi. Il compilatore seleziona la firma appropriata in base agli argomenti passati nella chiamata.
// Firme di sovraccarico: definiscono i contratti pubblici
function parseInput(input: string): string[];
function parseInput(input: string, separator: RegExp): string[];
function parseInput(input: number): string;
// Implementazione: deve gestire tutti i casi definiti sopra
function parseInput(input: string | number, separator?: RegExp): string | string[] {
if (typeof input === "number") {
// Converte il numero in stringa
return input.toString();
}
if (separator) {
// Divide la stringa usando l'espressione regolare
return input.split(separator);
}
// Divide la stringa per spazi bianchi
return input.split(/\s+/);
}
// TypeScript seleziona automaticamente la firma corretta
const words = parseInput("ciao mondo"); // string[]
const parts = parseInput("a,b,c", /,/); // string[]
const numericString = parseInput(42); // string
Tipi utility avanzati con generics vincolati
Combinando generics vincolati, tipi condizionali e tipi mappati si possono costruire utility types sofisticati che trasformano la struttura dei tipi in modo chirurgico.
// Rende obbligatorie solo le chiavi specificate, lasciando le altre invariate
type RequireKeys<T, K extends keyof T> = T & Required<Pick<T, K>>;
interface FormData {
username?: string;
email?: string;
bio?: string;
avatar?: string;
}
// Solo username e email sono obbligatori, il resto rimane opzionale
type RegistrationForm = RequireKeys<FormData, "username" | "email">;
// Estrae da T solo le proprietà il cui tipo è assegnabile a Condition
type PickByType<T, Condition> = {
[K in keyof T as T[K] extends Condition ? K : never]: T[K];
};
interface Analytics {
pageViews: number;
sessionDuration: number;
referrer: string;
isNewUser: boolean;
bounceRate: number;
}
// Estrae solo le proprietà numeriche
type NumericMetrics = PickByType<Analytics, number>;
// Risultato: { pageViews: number; sessionDuration: number; bounceRate: number }
// Crea un tipo in cui almeno uno dei campi dell'unione di chiavi è obbligatorio
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
Omit<T, Keys> & {
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
}[Keys];
interface SearchFilters {
query?: string;
category?: string;
dateRange?: [Date, Date];
}
// Almeno un filtro deve essere specificato
type ValidSearch = RequireAtLeastOne<SearchFilters>;
Const assertions e narrowing letterale
Le const assertion (as const) permettono di fare in modo che TypeScript inferisca il tipo più stretto possibile per un valore, trasformando array in tuple di sola lettura e stringhe in tipi letterali. Questo è fondamentale per creare configurazioni fortemente tipizzate.
// Senza "as const", TypeScript inferisce string[] e { role: string }
const permissions = ["read", "write", "admin"]; // string[]
// Con "as const", TypeScript inferisce il tipo letterale esatto
const strictPermissions = ["read", "write", "admin"] as const;
// Tipo: readonly ["read", "write", "admin"]
// Ora possiamo derivare un tipo unione dai valori effettivi
type Permission = typeof strictPermissions[number]; // "read" | "write" | "admin"
// Pattern per creare un oggetto di configurazione completamente tipizzato
const httpMethods = {
GET: "GET",
POST: "POST",
PUT: "PUT",
DELETE: "DELETE",
} as const;
// Tipo derivato automaticamente dai valori dell'oggetto
type HttpMethod = typeof httpMethods[keyof typeof httpMethods];
// Risultato: "GET" | "POST" | "PUT" | "DELETE"
function sendRequest(url: string, method: HttpMethod): void {
// Solo i valori definiti nell'oggetto sono accettati
console.log(`${method} ${url}`);
}
sendRequest("/api/users", httpMethods.GET); // Corretto
// sendRequest("/api/users", "PATCH"); // Errore: "PATCH" non è un HttpMethod
Decoratori
I decoratori sono una funzionalità che permette di annotare e modificare classi, metodi, proprietà e parametri in modo dichiarativo. A partire da TypeScript 5.0, i decoratori seguono la proposta TC39 Stage 3, che ne standardizza il comportamento.
// Decoratore che registra ogni invocazione di un metodo
function logExecution<TThis, TArgs extends unknown[], TReturn>(
originalMethod: (this: TThis, ...args: TArgs) => TReturn,
context: ClassMethodDecoratorContext<TThis, (this: TThis, ...args: TArgs) => TReturn>
): (this: TThis, ...args: TArgs) => TReturn {
const methodName = String(context.name);
return function (this: TThis, ...args: TArgs): TReturn {
console.log(`Chiamata a ${methodName} con argomenti:`, args);
const startTime = performance.now();
const result = originalMethod.call(this, ...args);
const elapsed = performance.now() - startTime;
console.log(`${methodName} completato in ${elapsed.toFixed(2)}ms`);
return result;
};
}
class DataProcessor {
// Il decoratore avvolge automaticamente il metodo
@logExecution
transform(input: string[]): string[] {
return input.map(item => item.toUpperCase());
}
}
Tipi di utilità avanzata con satisfies
L'operatore satisfies, introdotto in TypeScript 4.9, permette di verificare che un'espressione soddisfi un tipo senza allargare il tipo inferito. Questo dà il meglio di entrambi i mondi: validazione della conformità e preservazione dell'inferenza letterale.
type ColorMap = Record<string, string | [number, number, number]>;
// Con satisfies, TypeScript verifica la conformità ma mantiene i tipi specifici
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies ColorMap;
// TypeScript sa che red è una tupla, non string | [number, number, number]
const redChannel = palette.red[0]; // number, senza bisogno di narrowing
// TypeScript sa che green è una stringa
const greenUpper = palette.green.toUpperCase(); // Funziona senza errori
// Combinazione di "as const" e "satisfies" per il massimo della precisione
type RouteDefinition = {
path: string;
method: "GET" | "POST" | "PUT" | "DELETE";
auth: boolean;
};
const apiRoutes = {
getUsers: {
path: "/api/users",
method: "GET",
auth: true,
},
createUser: {
path: "/api/users",
method: "POST",
auth: true,
},
healthCheck: {
path: "/health",
method: "GET",
auth: false,
},
} as const satisfies Record<string, RouteDefinition>;
// Il tipo di method è il letterale "GET", non la generica stringa "GET" | "POST" | ...
type HealthMethod = typeof apiRoutes.healthCheck.method; // "GET"
Pattern matching esaustivo
TypeScript può verificare a tempo di compilazione che tutti i casi di un'unione siano gestiti. Combinando il tipo never con una funzione di controllo esaustivo, si ottiene un errore di compilazione ogni volta che un nuovo caso viene aggiunto all'unione ma non gestito nel codice.
// Funzione sentinella: se viene raggiunta, significa che un caso non è stato gestito
function assertNever(value: never): never {
throw new Error(`Caso non gestito: ${JSON.stringify(value)}`);
}
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function computeArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// Se si aggiunge un nuovo kind a Shape senza un caso qui,
// TypeScript segnalerà un errore di compilazione
return assertNever(shape);
}
}
Generics avanzati con vincoli multipli
I parametri generici possono essere vincolati a estendere intersezioni di tipi, imponendo che il tipo passato soddisfi contemporaneamente più contratti. Questo permette di scrivere funzioni che richiedono garanzie strutturali composite.
// Vincola T a essere sia serializzabile che identificabile
interface Identifiable {
id: string;
}
interface Serializable {
toJSON(): string;
}
// T deve soddisfare entrambe le interfacce
function persistEntity<T extends Identifiable & Serializable>(entity: T): void {
const serialized = entity.toJSON();
console.log(`Salvataggio dell'entità ${entity.id}: ${serialized}`);
}
class Product implements Identifiable, Serializable {
constructor(
public id: string,
public name: string,
public price: number
) {}
toJSON(): string {
return JSON.stringify({ id: this.id, name: this.name, price: this.price });
}
}
// Funziona: Product soddisfa entrambi i vincoli
persistEntity(new Product("p-001", "Tastiera meccanica", 89.99));
// Funzione type-safe per fare il merge profondo di due oggetti
function deepMerge<
TTarget extends Record<string, unknown>,
TSource extends Record<string, unknown>
>(target: TTarget, source: TSource): TTarget & TSource {
const result = { ...target } as TTarget & TSource;
for (const key of Object.keys(source)) {
const targetValue = (target as Record<string, unknown>)[key];
const sourceValue = (source as Record<string, unknown>)[key];
if (
targetValue &&
sourceValue &&
typeof targetValue === "object" &&
typeof sourceValue === "object" &&
!Array.isArray(targetValue) &&
!Array.isArray(sourceValue)
) {
// Fusione ricorsiva per gli oggetti annidati
(result as Record<string, unknown>)[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>
);
} else {
// Sovrascrittura diretta per i valori primitivi e gli array
(result as Record<string, unknown>)[key] = sourceValue;
}
}
return result;
}
Conclusione
Le funzionalità avanzate di TypeScript trasformano il sistema di tipi in un vero e proprio linguaggio di programmazione a livello di tipo. Tipi condizionali, inferenza con infer, tipi mappati, template literal types, tipi ricorsivi e branded types non sono esercizi accademici: sono strumenti concreti che permettono di catturare invarianti complesse a tempo di compilazione, eliminando intere classi di bug prima ancora che il codice venga eseguito. La padronanza di queste funzionalità non solo migliora la qualità e la robustezza del codice, ma cambia radicalmente il modo in cui si progettano le API e si modellano i domini applicativi.