Se scrivi TypeScript da un po' ma i generici ti sembrano ancora qualcosa che copi-incolli da Stack Overflow senza capire appieno, questo è l'articolo che fa per te. I generici sono la funzionalità che porta TypeScript da "JavaScript con annotazioni di tipo" a un sistema di tipi genuinamente espressivo. Una volta che scattano, li vedrai ovunque — e li userai naturalmente invece di ricorrere a any quando le cose si complicano. Il capitolo sui generici del TypeScript Handbook copre la specifica completa; questo articolo si concentra sui pattern che userai davvero in codebase reali.

Il Problema con any

I generici esistono per risolvere un problema specifico: vuoi che una funzione o una struttura dati funzioni con più tipi, ma non vuoi perdere informazioni sul tipo nel processo. La soluzione ingenua è any — e sembra a posto finché non ti rendi conto di cosa hai perso:

ts
// With any — TypeScript has no idea what comes out
function identity(arg: any): any {
  return arg;
}

const result = identity('hello');
// result is typed as 'any' — you've lost the string type
// TypeScript won't catch this:
result.toFixed(2); // no error at compile time, crashes at runtime

Le informazioni sul tipo entrano, ma non escono. Qualsiasi cosa tu faccia con result è completamente non verificata. Non è una codebase TypeScript — è JavaScript con passaggi extra. I generici risolvono questo permettendoti di dire: "Non conosco ancora il tipo esatto, ma qualsiasi cosa entri dovrebbe uscire con lo stesso tipo."

ts
// With a generic — T flows through the function
function identity<T>(arg: T): T {
  return arg;
}

const result = identity('hello');
// result is typed as 'string' ✅
result.toFixed(2); // ❌ TypeScript catches this: Property 'toFixed' does not exist on type 'string'

const count = identity(42);
// count is typed as 'number' ✅

Il <T> dichiara un parametro di tipo — pensalo come una variabile per i tipi. TypeScript inferisce cosa sia T dall'argomento che passi, quindi quasi non devi mai scriverlo esplicitamente. identity<string>('hello') funziona, ma anche identity('hello') — TypeScript lo capisce.

Funzioni Generiche in Pratica

La funzione identità è l'esempio canonico di insegnamento ma non è qualcosa che scriveresti in produzione. Ecco i tipi di funzioni generiche che compaiono davvero nelle codebase reali:

ts
// A type-safe array first/last helper
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

function last<T>(arr: T[]): T | undefined {
  return arr[arr.length - 1];
}

const firstUser = first(users);       // UserProfile | undefined
const lastOrder = last(orders);       // Order | undefined
const firstNum  = first([1, 2, 3]);   // number | undefined

// Group array items by a key
function groupBy<T, K extends string | number>(
  items: T[],
  getKey: (item: T) => K
): Record<K, T[]> {
  return items.reduce((acc, item) => {
    const key = getKey(item);
    if (!acc[key]) acc[key] = [];
    acc[key].push(item);
    return acc;
  }, {} as Record<K, T[]>);
}

// TypeScript infers T as Order and K as string
const byStatus = groupBy(orders, order => order.status);
// byStatus: Record<string, Order[]>
// byStatus['pending'] is Order[] ✅

Nota come TypeScript inferisca automaticamente T e K dal modo in cui chiami la funzione. Ottieni la piena sicurezza di tipo sul valore di ritorno senza scrivere una singola annotazione di tipo esplicita nel punto di chiamata. Questo è il risultato.

Interfacce e Tipi Generici

Qui i generici diventano indispensabili per il lavoro quotidiano. Ogni codebase che comunica con un'API finisce per avere bisogno di un paio di tipi wrapper generici. Ecco quelli che vedrai (e scriverai) costantemente:

ts
// API response wrapper — used across every endpoint
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}

// Paginated list response
interface PaginatedResult<T> {
  items: T[];
  page: number;
  pageSize: number;
  totalItems: number;
  totalPages: number;
}

// Using them with concrete types
interface UserProfile {
  id: number;
  name: string;
  email: string;
  avatarUrl: string;
}

interface Order {
  id: number;
  userId: number;
  total: number;
  status: 'pending' | 'shipped' | 'delivered';
}

// The return types are fully typed — no casting needed downstream
async function getUser(id: number): Promise<ApiResponse<UserProfile>> {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

async function listOrders(page: number): Promise<PaginatedResult<Order>> {
  const res = await fetch(`/api/orders?page=${page}`);
  return res.json();
}

const response = await getUser(1);
response.data.email;        // ✅ string
response.data.avatarUrl;    // ✅ string

const result = await listOrders(1);
result.items[0].status;     // ✅ 'pending' | 'shipped' | 'delivered'
result.totalPages;          // ✅ number
Quando costruisci questi tipi da payload API reali, lo strumento JSON to TypeScript può generare automaticamente i tipi interni (UserProfile, Order) da una risposta campione. Poi li colleghi ai tuoi wrapper generici.

Vincoli Generici

A volte vuoi accettare più tipi, ma non assolutamente qualsiasi tipo. I vincoli generici ti permettono di specificare cosa deve avere un parametro di tipo. La sintassi è T extends SomeType — che significa "T deve essere assegnabile a SomeType":

ts
// Without constraint — TypeScript doesn't know T has an id property
function findById<T>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id); // ❌ Property 'id' does not exist on type 'T'
}

// With constraint — T must have at least an id: number field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id); // ✅
}

// Works with any type that has id: number
const user  = findById(users, 42);   // UserProfile | undefined
const order = findById(orders, 101); // Order | undefined

// Another common constraint — T must be an object (excludes primitives)
function mergeDefaults<T extends object>(partial: Partial<T>, defaults: T): T {
  return { ...defaults, ...partial };
}

// T must have a name: string and email: string
function formatContact<T extends { name: string; email: string }>(contact: T): string {
  return `${contact.name} <${contact.email}>`;
}

// Works on any object with those two fields — UserProfile, Employee, whatever
formatContact({ name: 'Alice', email: '[email protected]', role: 'admin' }); // ✅

Il vincolo non blocca T esattamente a { id: number } — significa che T deve avere almeno quella forma. Quindi passare un UserProfile completo con dieci campi va bene. Questo è il typing strutturale in azione, che è una delle funzionalità più potenti e ben documentate di TypeScript.

Il Vincolo keyof

Uno dei pattern generici più utili nella libreria standard TypeScript e nelle codebase reali è la combinazione di generici con keyof. Ti permette di scrivere funzioni che accettano il nome di una proprietà di un tipo oggetto e garantiscono che il tipo di ritorno corrisponda a quella proprietà.

ts
// keyof T is the union of all keys of T as string literals
// K extends keyof T means K must be one of those keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user: UserProfile = {
  id: 1,
  name: 'Alice',
  email: '[email protected]',
  avatarUrl: 'https://example.com/avatar.png'
};

const name  = getProperty(user, 'name');  // string ✅
const id    = getProperty(user, 'id');    // number ✅
const email = getProperty(user, 'email'); // string ✅

getProperty(user, 'password'); // ❌ Argument of type '"password"' is not assignable to
                               //    parameter of type 'keyof UserProfile'

// Real use case: a generic sort function
function sortBy<T>(items: T[], key: keyof T): T[] {
  return [...items].sort((a, b) => {
    const av = a[key];
    const bv = b[key];
    return av < bv ? -1 : av > bv ? 1 : 0;
  });
}

const byName = sortBy(users, 'name');   // ✅ sorted by name
const byId   = sortBy(users, 'id');     // ✅ sorted by id
sortBy(users, 'nonexistent');           // ❌ caught at compile time

Parametri di Tipo con Default

Proprio come i parametri delle funzioni possono avere default, i parametri di tipo possono averli anche loro. Questo è utile quando hai un tipo generico che viene quasi sempre usato con un tipo specifico, ma vuoi mantenere la flessibilità per i casi in cui non è così.

ts
// Default type parameter — T defaults to string if not specified
interface Cache<T = string> {
  get(key: string): T | undefined;
  set(key: string, value: T, ttlMs?: number): void;
  delete(key: string): void;
  clear(): void;
}

// Without specifying T — uses the default (string)
declare const stringCache: Cache;
const val = stringCache.get('theme'); // string | undefined

// Specifying a different T
declare const userCache: Cache<UserProfile>;
const user2 = userCache.get('user:42'); // UserProfile | undefined

// Another useful example: an event emitter with a typed payload
interface TypedEvent<TPayload = void> {
  subscribe(handler: (payload: TPayload) => void): () => void;
  emit(payload: TPayload): void;
}

// Events with no payload — default void keeps the API clean
const appReady: TypedEvent = { /* ... */ };
appReady.emit(); // no argument needed

// Events with a payload
const userLoggedIn: TypedEvent<{ userId: number; timestamp: Date }> = { /* ... */ };
userLoggedIn.emit({ userId: 42, timestamp: new Date() });

Tipi Utility Generici Incorporati

TypeScript include un insieme di tipi utility incorporati che sono tutti implementati usando i generici che hai imparato sopra. Capire come usarli è una delle competenze TypeScript più pratiche che puoi avere. Ecco il set principale con esempi reali che mostrano il tipo di output:

ts
interface UserProfile {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'viewer';
  passwordHash: string;
  createdAt: Date;
}

// Partial<T> — all fields become optional (great for PATCH payloads)
type UpdateUserPayload = Partial<UserProfile>;
// { id?: number; name?: string; email?: string; role?: ...; ... }

// Required<T> — all fields become required (reverse of Partial)
interface DraftConfig {
  apiUrl?: string;
  timeout?: number;
  maxRetries?: number;
}
type ResolvedConfig = Required<DraftConfig>;
// { apiUrl: string; timeout: number; maxRetries: number }

// Pick<T, K> — keep only the named fields
type UserSummary = Pick<UserProfile, 'id' | 'name' | 'email'>;
// { id: number; name: string; email: string }
// Use in list views — only ship what the UI needs

// Omit<T, K> — exclude the named fields
type PublicUserProfile = Omit<UserProfile, 'passwordHash'>;
// { id: number; name: string; email: string; role: ...; createdAt: Date }
// Safe to include in API responses

// Record<K, V> — typed dictionary / map
type PermissionMap = Record<UserProfile['role'], string[]>;
// { admin: string[]; user: string[]; viewer: string[] }

const permissions: PermissionMap = {
  admin:  ['read', 'write', 'delete', 'admin'],
  user:   ['read', 'write'],
  viewer: ['read']
};

// ReturnType<T> — infer what a function returns (keeps types in sync automatically)
function buildUserSession(user: UserProfile) {
  return {
    token: crypto.randomUUID(),
    userId: user.id,
    role: user.role,
    expiresAt: new Date(Date.now() + 3_600_000)
  };
}

type UserSession = ReturnType<typeof buildUserSession>;
// { token: string; userId: number; role: 'admin' | 'user' | 'viewer'; expiresAt: Date }
// Change buildUserSession and UserSession updates automatically ✅

Se stai convertendo JavaScript esistente in TypeScript e costruendo questi tipi da zero, lo strumento JS to TypeScript può aiutarti a mettere in piazza i tipi iniziali così puoi iniziare ad aggiungere i tipi utility sopra.

Un Esempio End-to-End Realistico: Client API Tipizzato

Ecco il pattern a cui mi rivolgo ogni volta che costruisco un layer API tipizzato. Combina funzioni generiche, interfacce generiche e tipi utility in qualcosa che ti dà sicurezza di tipo end-to-end dalla chiamata fetch fino al componente che consuma i dati:

ts
// The response envelope — wraps every API response
interface ApiResponse<T> {
  data: T;
  meta: {
    requestId: string;
    duration: number;
  };
}

// Error envelope — what the API sends on failure
interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}

// The generic fetch wrapper
async function fetchApi<T>(
  url: string,
  options?: RequestInit
): Promise<ApiResponse<T>> {
  const res = await fetch(url, {
    headers: { 'Content-Type': 'application/json', ...options?.headers },
    ...options
  });

  if (!res.ok) {
    const err: ApiError = await res.json();
    throw new Error(`[${err.code}] ${err.message}`);
  }

  return res.json();
}

// Typed endpoint functions — T is set at each call site
async function getUser(id: number): Promise<UserProfile> {
  const { data } = await fetchApi<UserProfile>(`/api/users/${id}`);
  return data;
}

async function listOrders(userId: number, page = 1): Promise<PaginatedResult<Order>> {
  const { data } = await fetchApi<PaginatedResult<Order>>(
    `/api/users/${userId}/orders?page=${page}`
  );
  return data;
}

async function createOrder(
  payload: Pick<Order, 'userId' | 'total'>
): Promise<Order> {
  const { data } = await fetchApi<Order>('/api/orders', {
    method: 'POST',
    body: JSON.stringify(payload)
  });
  return data;
}

// Usage — fully typed, no casting anywhere
const user = await getUser(42);
console.log(user.email);          // string ✅

const orders = await listOrders(42);
orders.items[0].status;           // 'pending' | 'shipped' | 'delivered' ✅
orders.totalPages;                // number ✅

const newOrder = await createOrder({ userId: 42, total: 99.99 });
newOrder.id;                      // number ✅
Puoi sperimentare con ognuno di questi pattern direttamente nel TypeScript Playground — incolla il codice, passa il mouse sulle variabili per vedere i tipi inferiti, e prova a rompere le cose per vedere cosa cattura il compilatore. È il ciclo di feedback più veloce per imparare i generici.

Errori Comuni

Alcuni pattern da evitare una volta che ti senti a tuo agio con i generici:

  • Usare any all'interno di un generico. Se scrivi function wrap<T>(val: T): any, hai vanificato lo scopo. Il punto è che il tipo fluisce attraverso — usare any come tipo di ritorno o dentro l'implementazione significa che TypeScript non può più tracciarlo.
  • Vincolare eccessivamente con tipi specifici. Scrivere function process<T extends UserProfile> quando hai bisogno solo di T extends { id: number } è troppo restrittivo. Usa il vincolo minimo che fa funzionare la tua implementazione — in questo modo la funzione rimane riutilizzabile in diversi tipi che hanno la stessa forma.
  • Ricorrere ai generici quando basterebbe un'unione. Se una funzione accetta sia una string che un number e la logica è diversa per ognuno, function f(arg: string | number) con un type guard dentro è più pulito di un generico. I generici brillano quando la stessa logica si applica a tutte le varianti di tipo.
  • Troppi parametri di tipo. Se ti ritrovi a scrivere <T, U, V, W>, fai un passo indietro. Di solito è un segnale che la funzione sta facendo troppo, o i tipi potrebbero essere espressi come una singola interfaccia. Il codice sorgente del compilatore TypeScript stesso è un buon riferimento — anche le utility complesse raramente necessitano di più di 2–3 parametri di tipo.

Conclusioni

I generici sono il punto in cui TypeScript smette di essere "JavaScript annotato" e diventa un sistema di tipi genuinamente potente. L'idea centrale è semplice: cattura il tipo come parametro in modo che fluisca attraverso la tua funzione o interfaccia senza perdere informazioni. Da lì, i vincoli ti permettono di restringere ciò che è accettabile, keyof ti dà accesso tipizzato alle proprietà, e i tipi utility incorporati (Partial, Pick, Omit, Record) gestiscono i pattern che altrimenti scriveresti a mano. Il miglior passo successivo è aprire un progetto reale, trovare una funzione in cui hai usato any perché non conoscevi un altro modo, e sostituirla con un generico. Il TypeScript Handbook sui generici e il riferimento dei tipi utility sono le due pagine che vale la pena aggiungere ai segnalibri. E se stai costruendo interfacce da payload API reali, JSON to TypeScript può generare i tipi concreti — poi è solo questione di avvolgerli nei tuoi tipi utility generici come ApiResponse<T> e PaginatedResult<T> per ottenere la piena sicurezza di tipo end-to-end.