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.
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.
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?"
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 | booleanQui 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.
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.
inferinfer è 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."
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.
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]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: numbertype ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberinfer in un solo tipoPuoi 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 }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 }I mapped types ti permettono di creare nuovi tipi oggetto trasformando ogni proprietà di un tipo esistente. Sono il map() del type system.
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à.
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 }asTypeScript 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.
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.
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.
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"È 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.
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"); // ErrorePuoi 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.
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.
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.
TypeScript supporta le definizioni di tipo ricorsive, il che ti permette di gestire strutture annidate arbitrariamente.
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.
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.
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à.
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.
Lascia che condivida alcuni pattern che ho effettivamente usato in sistemi di produzione.
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.
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.
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}`);
}
}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.
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.
I tipi avanzati sono potenti, ma presentano trappole. Ecco cosa ho imparato a mie spese.
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.
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:
tsc --noEmit impiega notevolmente più tempo dopo aver aggiunto un tipo// 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 elementiQuesta 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:
Record<string, unknown> basterebbeUsali quando:
// 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.
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 controllareT 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.{ [K in keyof T]: ... }) iterano sulle proprietà. Combinali con as per il key remapping e il filtraggio.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.