Vai al contenuto
·21 min di lettura

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.

Condividi:X / TwitterLinkedIn

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:

typescript
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:

typescript
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:

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

La 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:

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Risultato: string

Il 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:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Risultato: number | boolean

Exclude — l'opposto, rimuove i membri che corrispondono:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Risultato: number | boolean

Distributive 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.

typescript
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:

typescript
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:

typescript
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: string

Questo è 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#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScript vede il pattern (...args) => something, confronta la tua funzione con esso, e assegna il tipo di ritorno a R.

Estrarre i Parameter Types#

typescript
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:

typescript
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:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Risultato: number

Estrarre i tipi degli elementi di un Array#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

Più infer in un solo tipo#

Puoi usare infer più volte per estrarre parti diverse:

typescript
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:

typescript
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#

typescript
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:

typescript
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

La sintassi -? rimuove il modificatore opzionale. Analogamente, -readonly rimuove readonly.

Pick — selezionare proprietà specifiche:

typescript
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:

typescript
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:

typescript
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à:

typescript
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:

typescript
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:

typescript
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#

typescript
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:

typescript
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:

typescript
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:

typescript
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");  // Errore

Parsing di stringhe con Template Literal#

Puoi usare i template literal types con infer per fare il parsing delle stringhe:

typescript
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:

typescript
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:

typescript
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:

typescript
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: string

Autocompletamento 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:

typescript
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 opzionale

Nota 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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' mancante

Il 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:

typescript
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 AppEvents

Ho 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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 UserId

Sia 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".

typescript
// 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 --noEmit impiega notevolmente più tempo dopo aver aggiunto un tipo
  • Vedi errori "Type instantiation is excessively deep"
typescript
// 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 elementi

Quando 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
typescript
// 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:

typescript
// 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 risolto

Il 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:

typescript
// 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 controllare

TL;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 con as per 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.

Articoli correlati