Tipi avanzati di TypeScript: la magia dei Conditional, Mapped e Template Literal Types
Un'immersione profonda nelle funzionalità più potenti del type system di TypeScript — conditional types, mapped types, la keyword infer e i template literal types. Pattern reali che userai davvero.
La maggior parte degli sviluppatori TypeScript si blocca allo stesso punto. Conoscono le interfacce, i generics, gli union types. Sanno tipizzare un componente React. Riescono a far sparire le sottolineature rosse. Ma trattano il type system come un male necessario — qualcosa che esiste per prevenire bug, non qualcosa che li aiuta attivamente a progettare software migliore.
Sono rimasto a quel plateau per circa due anni. Quello che mi ha sbloccato è stato capire che il type system di TypeScript è un linguaggio di programmazione a sé stante. Ha condizionali, cicli, pattern matching, ricorsione e manipolazione di stringhe. Una volta interiorizzato questo concetto, cambia tutto. Smetti di combattere il compilatore e inizi a collaborarci.
Questo post copre le funzionalità a livello di tipo che uso costantemente nel codice di produzione. Non esercizi accademici — pattern reali che mi hanno salvato da bug reali.
Il mindset della programmazione a livello di tipo#
Prima di tuffarci nella sintassi, voglio riformulare il modo in cui pensi ai tipi.
Nella programmazione a livello di valore, scrivi funzioni che trasformano dati a runtime:
function extractIds(users: User[]): number[] {
return users.map(u => u.id);
}Nella programmazione a livello di tipo, scrivi tipi che trasformano altri tipi a compile time:
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];Il modello mentale è lo stesso — input, trasformazione, output. La differenza è che il codice a livello di tipo viene eseguito durante la compilazione, non a runtime. Produce zero JavaScript. Il suo unico scopo è rendere gli stati impossibili non rappresentabili.
Se hai mai scritto una funzione dove tre dei quattro parametri hanno senso solo in certe combinazioni, o dove il tipo di ritorno dipende da cosa passi, hai già avuto bisogno della programmazione a livello di tipo. Semplicemente potresti non aver saputo che TypeScript potesse esprimerlo.
Conditional Types#
I conditional types sono l'if/else del type system. La sintassi sembra un ternario:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueLa keyword extends qui non significa ereditarietà. Significa "è assegnabile a." Pensala come "T rientra nella forma di string?"
Costruire i propri Utility Types#
Ricostruiamo alcuni utility types built-in di TypeScript per capire come funzionano.
NonNullable — rimuove null e undefined da un union:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// Risultato: stringIl tipo never è l'"insieme vuoto" — rimuovere qualcosa da un union. Quando un ramo di un conditional type si risolve in never, quel membro dell'union semplicemente sparisce.
Extract — mantiene solo i membri dell'union che soddisfano un vincolo:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Risultato: number | booleanExclude — l'opposto, rimuove i membri che corrispondono:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Risultato: number | booleanDistributive Conditional Types#
Qui le cose si fanno interessanti, e qui la maggior parte delle persone si confonde. Quando passi un union type a un conditional type, TypeScript distribuisce la condizione su ogni membro dell'union individualmente.
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// Potresti aspettarti: (string | number)[]
// Risultato effettivo: string[] | number[]TypeScript non valuta (string | number) extends unknown. Invece, valuta string extends unknown e number extends unknown separatamente, poi unisce i risultati.
Ecco perché Extract e Exclude funzionano come funzionano. La distribuzione avviene automaticamente quando T è un type parameter "nudo" (non wrappato in nient'altro).
Se vuoi prevenire la distribuzione, wrappa entrambi i lati in una tupla:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Result = ToArrayNonDist<string | number>;
// Risultato: (string | number)[]Mi sono fatto fregare da questo più volte di quante vorrei ammettere. Se il tuo conditional type dà risultati inattesi con input union, la distribuzione è quasi sempre la causa.
Esempio pratico: gestione delle risposte API#
Ecco un pattern che uso quando gestisco risposte API che possono avere successo o fallire:
type ApiResponse<T> =
| { status: "success"; data: T; error: never }
| { status: "error"; data: never; error: string };
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
type UserResponse = ApiResponse<{ id: number; name: string }>;
type UserData = ExtractData<UserResponse>;
// Risultato: { id: number; name: string }
type UserError = ExtractError<UserResponse>;
// Risultato: stringQuesto è più pulito che scrivere type guard manuali per ogni forma di risposta.
La keyword infer#
infer è il pattern matching per i tipi. Ti permette di dichiarare una variabile di tipo all'interno di un conditional type che TypeScript capirà per te.
Pensala come: "So che questo tipo ha una certa forma. Estrai la parte che mi interessa."
Estrarre i Return Types#
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript vede il pattern (...args) => something, confronta la tua funzione con esso, e assegna il tipo di ritorno a R.
Estrarre i Parameter Types#
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]Unwrapping delle Promise#
Questo lo uso continuamente quando lavoro con funzioni async:
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>; // string (passthrough)Per Promise profondamente annidate:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Risultato: numberEstrarre i tipi degli elementi di un Array#
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberPiù infer in un solo tipo#
Puoi usare infer più volte per estrarre parti diverse:
type FirstAndLast<T extends unknown[]> =
T extends [infer First, ...unknown[], infer Last]
? { first: First; last: Last }
: never;
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Risultato: { first: 1; last: 4 }Caso reale: estrazione type-safe di eventi#
Ecco un pattern che uso nei sistemi event-driven:
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
"order:shipped": { orderId: string; trackingId: string };
};
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
// Estrarre tutti gli eventi relativi all'utente
type UserEvents = Extract<EventName, `user:${string}`>;
// Risultato: "user:login" | "user:logout"
// Ottenere il payload solo per gli eventi utente
type UserEventPayloads = EventPayload<UserEvents>;
// Risultato: { userId: string; timestamp: number } | { userId: string }Mapped Types#
I mapped types ti permettono di creare nuovi tipi oggetto trasformando ogni proprietà di un tipo esistente. Sono il map() del type system.
Le basi#
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};keyof T ti dà un union di tutte le chiavi delle proprietà. [K in ...] itera su ognuna. T[K] è il tipo di quella proprietà.
Costruire Utility Types da zero#
Required — rendere tutte le proprietà obbligatorie:
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};La sintassi -? rimuove il modificatore opzionale. Analogamente, -readonly rimuove readonly.
Pick — selezionare proprietà specifiche:
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
interface User {
id: number;
name: string;
email: string;
password: string;
}
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }Omit — rimuovere proprietà specifiche:
type MyOmit<T, K extends keyof T> = {
[P in Exclude<keyof T, K>]: T[P];
};
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }Record — creare un tipo oggetto con chiavi e tipi di valore specifici:
type MyRecord<K extends string | number | symbol, V> = {
[P in K]: V;
};
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }Key Remapping con as#
TypeScript 4.1 ha introdotto il key remapping, che ti permette di trasformare i nomi delle proprietà:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
email: string;
}
type PersonGetters = Getters<Person>;
// {
// getName: () => string;
// getAge: () => number;
// getEmail: () => string;
// }Puoi anche filtrare le proprietà rimappando a never:
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }Questo è incredibilmente potente. Stai simultaneamente iterando sulle proprietà, filtrandole e trasformando i loro nomi — tutto a livello di tipo.
Esempio pratico: tipi per la validazione dei form#
Ho usato questo pattern in diverse librerie per i form:
interface FormFields {
username: string;
email: string;
age: number;
bio: string;
}
type ValidationErrors<T> = {
[K in keyof T]?: string[];
};
type TouchedFields<T> = {
[K in keyof T]?: boolean;
};
type DirtyFields<T> = {
[K in keyof T]?: boolean;
};
type FormState<T> = {
values: T;
errors: ValidationErrors<T>;
touched: TouchedFields<T>;
dirty: DirtyFields<T>;
isValid: boolean;
isSubmitting: boolean;
};
// Ora lo stato del tuo form è completamente tipizzato in base ai campi
type MyFormState = FormState<FormFields>;Aggiungi un campo a FormFields, e ogni tipo correlato si aggiorna automaticamente. Rimuovi un campo, e il compilatore cattura ogni riferimento. Questo è il genere di cose che previene intere categorie di bug.
Template Literal Types#
I template literal types ti permettono di manipolare stringhe a livello di tipo. Usano la stessa sintassi con backtick dei template literal JavaScript, ma per i tipi.
Manipolazione base delle stringhe#
type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, world"; // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world"; // Errore!TypeScript fornisce anche tipi built-in per la manipolazione delle stringhe:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"Pattern per i nomi degli eventi#
È qui che i template literal types brillano davvero. Ecco un pattern che uso per sistemi di eventi type-safe:
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"Nove eventi da due type parameter. E sono tutti type-safe.
Pattern CSS-in-TypeScript#
I template literal types possono imporre pattern CSS a livello di tipo:
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
function setWidth(value: CSSValue): void {
// implementazione
}
setWidth("100px"); // OK
setWidth("2.5rem"); // OK
setWidth("50%"); // OK
// setWidth("100"); // Errore: non è un valore CSS valido
// setWidth("big"); // ErroreParsing di stringhe con Template Literal#
Puoi usare i template literal types con infer per fare il parsing delle stringhe:
type ParseRoute<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ParseRoute<`/${Rest}`>
: T extends `${infer _Start}:${infer Param}`
? Param
: never;
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Risultato: "userId" | "postId"Questa è la base di come funzionano le librerie di routing type-safe come tRPC. La stringa /users/:userId/posts/:postId viene parsata a livello di tipo per estrarre i nomi dei parametri.
Combinare Template Literal con Mapped Types#
Qui le cose diventano davvero potenti:
type EventHandlers<T extends Record<string, unknown>> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (
newValue: T[K],
oldValue: T[K]
) => void;
};
interface ThemeConfig {
color: string;
fontSize: number;
darkMode: boolean;
}
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
// onColorChange: (newValue: string, oldValue: string) => void;
// onFontSizeChange: (newValue: number, oldValue: number) => void;
// onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }Da una semplice interfaccia di configurazione, ottieni event handler completamente tipizzati con i tipi dei parametri corretti. Questo è il genere di codice a livello di tipo che effettivamente fa risparmiare tempo di sviluppo.
Avanzato: tipi per percorsi in dot-notation#
Ecco un pattern più avanzato — generare tutti i possibili percorsi in dot-notation attraverso un oggetto annidato:
type DotPaths<T, Prefix extends string = ""> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string]
: never;
interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
server: {
port: number;
};
}
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"Ora puoi scrivere una funzione getConfig che autocompleta ogni possibile percorso e restituisce il tipo corretto per ciascuno:
type GetValueByPath<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? GetValueByPath<T[Key], Rest>
: never
: P extends keyof T
? T[P]
: never;
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
// implementazione
const keys = path.split(".");
let result: unknown = config;
for (const key of keys) {
result = (result as Record<string, unknown>)[key];
}
return result as GetValueByPath<Config, P>;
}
const host = getConfig("database.host"); // tipo: string
const port = getConfig("database.port"); // tipo: number
const username = getConfig("database.credentials.username"); // tipo: stringAutocompletamento completo, type safety completa, zero overhead a runtime dai tipi.
Tipi ricorsivi#
TypeScript supporta le definizioni di tipo ricorsive, il che ti permette di gestire strutture annidate arbitrariamente.
Deep Partial#
Il built-in Partial<T> rende opzionali solo le proprietà di primo livello. Per gli oggetti annidati, serve la ricorsione:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? T[K] extends Array<infer U>
? Array<DeepPartial<U>>
: DeepPartial<T[K]>
: T[K];
};
interface Settings {
theme: {
colors: {
primary: string;
secondary: string;
};
fonts: {
heading: string;
body: string;
};
};
notifications: {
email: boolean;
push: boolean;
};
}
type PartialSettings = DeepPartial<Settings>;
// Ogni proprietà annidata è ora opzionaleNota il controllo sull'array: senza di esso, gli array verrebbero trattati come oggetti e i loro indici numerici diventerebbero opzionali, il che non è quello che vuoi.
Deep Readonly#
Stesso pattern, modificatore diverso:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: T[K] extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: DeepReadonly<T[K]>
: T[K];
};Il controllo su Function è importante — senza di esso, le proprietà funzione avrebbero readonly applicato alla loro struttura interna, il che non ha senso.
Tipo JSON#
Ecco un classico tipo ricorsivo — rappresentare qualsiasi valore JSON valido:
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
function parseJson(input: string): JsonValue {
return JSON.parse(input);
}
function isJsonObject(value: JsonValue): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}Questo è molto meglio che usare any per il parsing JSON. Dice al consumatore "otterrai indietro qualcosa, ma devi restringerlo prima di usarlo." Che è esattamente la verità.
Tipo ricorsivo per menu annidati#
Un esempio pratico — tipizzare una struttura di menu di navigazione:
interface MenuItem {
label: string;
href: string;
icon?: string;
children?: MenuItem[];
}
type Menu = MenuItem[];
const navigation: Menu = [
{
label: "Products",
href: "/products",
children: [
{
label: "Software",
href: "/products/software",
children: [
{ label: "IDE", href: "/products/software/ide" },
{ label: "CLI Tools", href: "/products/software/cli" },
],
},
{ label: "Hardware", href: "/products/hardware" },
],
},
{ label: "About", href: "/about" },
];La proprietà ricorsiva children ti dà annidamento infinito con type safety completa.
Pattern del mondo reale#
Lascia che condivida alcuni pattern che ho effettivamente usato in sistemi di produzione.
Builder Pattern con i tipi#
Il builder pattern diventa significativamente più utile quando il type system tiene traccia di ciò che è stato impostato:
type RequiredKeys = "host" | "port" | "database";
type BuilderState = {
[K in RequiredKeys]?: true;
};
class DatabaseConfigBuilder<State extends BuilderState = {}> {
private config: Partial<{
host: string;
port: number;
database: string;
ssl: boolean;
poolSize: number;
}> = {};
setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
this.config.host = host;
return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
}
setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
this.config.port = port;
return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
}
setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
this.config.database = db;
return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
}
setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
this.config.ssl = ssl;
return this as unknown as DatabaseConfigBuilder<State>;
}
setPoolSize(size: number): DatabaseConfigBuilder<State> {
this.config.poolSize = size;
return this as unknown as DatabaseConfigBuilder<State>;
}
build(
this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
): Required<Pick<typeof this.config, "host" | "port" | "database">> &
Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
return this.config as ReturnType<typeof this.build>;
}
}
// Questo compila:
const config = new DatabaseConfigBuilder()
.setHost("localhost")
.setPort(5432)
.setDatabase("myapp")
.setSsl(true)
.build();
// Questo fallisce a compile time — manca .setDatabase():
// const bad = new DatabaseConfigBuilder()
// .setHost("localhost")
// .setPort(5432)
// .build(); // Errore: 'database' mancanteIl metodo build() diventa richiamabile solo quando tutti i campi obbligatori sono stati impostati. Questo viene catturato a compile time, non a runtime.
Event Emitter type-safe#
Ecco un event emitter dove i tipi del payload sono enforced:
type EventMap = Record<string, unknown>;
class TypedEventEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach((listener) => listener(payload));
}
off<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
const handlers = this.listeners[event];
if (handlers) {
this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
}
}
}
// Utilizzo
interface AppEvents {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"notification:new": { message: string; level: "info" | "warning" | "error" };
}
const emitter = new TypedEventEmitter<AppEvents>();
// Completamente tipizzato — l'IDE autocompleta i nomi degli eventi e le forme del payload
emitter.on("user:login", (payload) => {
console.log(payload.userId); // string
console.log(payload.timestamp); // number
});
// Errore di tipo: il payload non corrisponde
// emitter.emit("user:login", { userId: "123" });
// Errore: manca 'timestamp'
// Errore di tipo: l'evento non esiste
// emitter.on("user:signup", () => {});
// Errore: "user:signup" non è in AppEventsHo usato questo pattern in tre diversi progetti di produzione. Cattura intere categorie di bug legati agli eventi a compile time.
Discriminated Unions con controllo esaustivo#
Le discriminated unions sono probabilmente il singolo pattern più utile in TypeScript. Combinate con il controllo esaustivo, garantiscono che gestisci ogni caso:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
// Questa riga garantisce il controllo esaustivo
const _exhaustive: never = shape;
return _exhaustive;
}
}Se qualcuno aggiunge una nuova variante di forma (tipo "pentagon"), questa funzione non compilerà perché a never non può essere assegnato un valore. Il compilatore ti obbliga a gestire ogni caso.
Vado oltre con una funzione helper:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
}
}State Machine type-safe#
Le discriminated unions modellano le macchine a stati in modo splendido:
type ConnectionState =
| { status: "disconnected" }
| { status: "connecting"; attempt: number }
| { status: "connected"; socket: WebSocket; connectedAt: Date }
| { status: "error"; error: Error; lastAttempt: Date };
type ConnectionAction =
| { type: "CONNECT" }
| { type: "CONNECTED"; socket: WebSocket }
| { type: "DISCONNECT" }
| { type: "ERROR"; error: Error }
| { type: "RETRY" };
function connectionReducer(
state: ConnectionState,
action: ConnectionAction
): ConnectionState {
switch (state.status) {
case "disconnected":
if (action.type === "CONNECT") {
return { status: "connecting", attempt: 1 };
}
return state;
case "connecting":
if (action.type === "CONNECTED") {
return {
status: "connected",
socket: action.socket,
connectedAt: new Date(),
};
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "connected":
if (action.type === "DISCONNECT") {
state.socket.close();
return { status: "disconnected" };
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "error":
if (action.type === "RETRY") {
return { status: "connecting", attempt: 1 };
}
if (action.type === "DISCONNECT") {
return { status: "disconnected" };
}
return state;
default:
assertNever(state, "Unknown connection state");
}
}Ogni stato ha solo le proprietà che hanno senso per quello stato. Non puoi accedere a socket quando sei disconnesso. Non puoi accedere a error quando sei connesso. Il type system enforce i vincoli della macchina a stati.
Branded Types per la sicurezza di dominio#
Un altro pattern che trovo essenziale — usare i branded types per prevenire la confusione tra valori che condividono lo stesso tipo sottostante:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getUser(id: UserId): Promise<User> {
// ...
}
function getOrder(id: OrderId): Promise<Order> {
// ...
}
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(userId); // OK
// getUser(orderId); // Errore! OrderId non è assegnabile a UserIdSia UserId che OrderId sono stringhe a runtime. Ma a compile time, sono tipi distinti. Letteralmente non puoi passare un order ID dove è previsto un user ID. Questo ha catturato bug reali in ogni progetto dove l'ho usato.
Insidie comuni#
I tipi avanzati sono potenti, ma presentano trappole. Ecco cosa ho imparato a mie spese.
Limiti dei riferimenti circolari#
TypeScript ha un limite di profondità di ricorsione per i tipi (attualmente circa 50 livelli, anche se varia). Se vai troppo in profondità, otterrai il temuto errore "Type instantiation is excessively deep and possibly infinite".
// Questo raggiungerà il limite di ricorsione per oggetti profondamente annidati
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// Fix: aggiungi un contatore di profondità
type DeepPartialSafe<T, Depth extends number[] = []> =
Depth["length"] extends 10
? T
: {
[K in keyof T]?: T[K] extends object
? DeepPartialSafe<T[K], [...Depth, 0]>
: T[K];
};Il trucco del contatore di profondità usa una tupla che cresce ad ogni passo ricorsivo. Quando raggiunge il tuo limite, la ricorsione si ferma.
Implicazioni sulle prestazioni#
I tipi complessi possono rallentare significativamente il tuo editor e i tempi di build. Ho visto progetti dove un singolo tipo troppo ingegnoso aggiungeva 3 secondi al loop di feedback di ogni pressione di tasto.
Segnali di allarme:
- Il tuo IDE impiega più di 2 secondi per mostrare l'autocompletamento
tsc --noEmitimpiega notevolmente più tempo dopo aver aggiunto un tipo- Vedi errori "Type instantiation is excessively deep"
// Questo è troppo furbo — genera un'esplosione combinatoria
type AllCombinations<T extends string[]> = T extends [
infer First extends string,
...infer Rest extends string[]
]
? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
: "";
// Non farlo con più di 5-6 elementiQuando NON usare i tipi avanzati#
Questa potrebbe essere la sezione più importante. I tipi avanzati dovrebbero essere usati quando prevengono bug o migliorano l'esperienza dello sviluppatore. Non dovrebbero essere usati per mettersi in mostra.
Non usarli quando:
- Un semplice
Record<string, unknown>basterebbe - Il tipo è usato in un solo posto e un tipo concreto sarebbe più chiaro
- Stai spendendo più tempo a debuggare il tipo di quanto ne risparmi
- Il tuo team non riesce a leggerlo o mantenerlo
- Un controllo a runtime sarebbe più appropriato
Usali quando:
- Hai un pattern che si ripete in molti tipi (mapped types)
- Il tipo di ritorno di una funzione dipende dal suo input (conditional types)
- Stai costruendo un'API di una libreria che deve essere auto-documentante
- Vuoi rendere gli stati illegali non rappresentabili
- Sei stanco della stessa categoria di bug che appare nelle code review
// Troppo ingegnerizzato — non fare questo per una semplice config
type Config = DeepReadonly<
DeepRequired<
Merge<DefaultConfig, Partial<UserConfig>>
>
>;
// Fai semplicemente questo
interface Config {
readonly host: string;
readonly port: number;
readonly debug: boolean;
}La mia regola empirica: se spiegare il tipo richiede più tempo di spiegare il bug che previene, semplificalo.
Debuggare tipi complessi#
Quando un tipo complesso non funziona, uso questo helper per "vedere" cosa TypeScript ha risolto:
// Espande un tipo per l'ispezione nei tooltip hover dell'IDE
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
// Usalo per debuggare
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Ora passa il mouse sopra Debug nel tuo IDE per vedere il tipo risoltoIl trucco & {} forza TypeScript a valutare eagerly il tipo invece di mostrarti l'alias del tipo. Mi ha risparmiato ore di confusione.
Un'altra tecnica — isolare e testare incrementalmente:
// Invece di debuggare tutto in una volta:
type Final = StepThree<StepTwo<StepOne<Input>>>;
// Scomponilo:
type AfterStep1 = StepOne<Input>; // hover per controllare
type AfterStep2 = StepTwo<AfterStep1>; // hover per controllare
type AfterStep3 = StepThree<AfterStep2>; // hover per controllareTL;DR#
- Conditional types (
T extends U ? X : Y) sono l'if/else per i tipi. Fai attenzione al comportamento distributivo con gli union. inferè il pattern matching — usalo per estrarre tipi da strutture come firme di funzioni, promise e array.- Mapped types (
{ [K in keyof T]: ... }) iterano sulle proprietà. Combinali conasper il key remapping e il filtraggio. - Template literal types manipolano le stringhe a livello di tipo. Combinati con i mapped types, sono incredibilmente potenti per il design delle API.
- Tipi ricorsivi gestiscono strutture annidate ma necessitano di limiti di profondità per evitare esplosioni del compilatore.
- Discriminated unions con controllo esaustivo sono il singolo pattern con il valore più alto in TypeScript. Usale ovunque.
- Branded types prevengono la confusione tra valori che condividono lo stesso tipo sottostante. Semplici da implementare, alto impatto.
- Non sovra-ingegnerizzare i tipi. Se il tipo è più difficile da capire del bug che previene, semplificalo. L'obiettivo è rendere il tuo codebase più sicuro, non vincere una gara di type golf.
Il type system di TypeScript è Turing-completo, il che significa che puoi fare quasi qualsiasi cosa con esso. L'arte sta nel sapere quando dovresti.