Generics Classi

Edoardo Midali
Edoardo Midali

Le classi generiche in TypeScript permettono di creare strutture dati e componenti riutilizzabili che operano su tipi diversi mantenendo type safety completa. Dichiarate con parametri tipo <T>, le classi generiche definiscono placeholder per tipi che vengono specificati all’istanziazione, consentendo di scrivere codice una volta e utilizzarlo con qualsiasi tipo, eliminando duplicazione e garantendo correttezza a compile time.

Fondamenti e Sintassi

Una classe generica si dichiara aggiungendo parametri tipo tra angle brackets dopo il nome della classe.

// Classe generica base
class Box<T> {
  constructor(private contenuto: T) {}

  getContenuto(): T {
    return this.contenuto;
  }

  setContenuto(valore: T): void {
    this.contenuto = valore;
  }
}

// Istanziazione con tipo specifico
const boxNumero = new Box<number>(42);
console.log(boxNumero.getContenuto()); // 42

const boxStringa = new Box<string>("hello");
console.log(boxStringa.getContenuto()); // "hello"

// Inferenza del tipo dal costruttore
const boxAuto = new Box(true); // Box<boolean>

Multipli Parametri Tipo

Le classi possono avere multipli parametri tipo per gestire relazioni complesse tra tipi.

// Classe con due parametri tipo
class Coppia<K, V> {
  constructor(private chiave: K, private valore: V) {}

  getChiave(): K {
    return this.chiave;
  }

  getValore(): V {
    return this.valore;
  }

  setValore(nuovoValore: V): void {
    this.valore = nuovoValore;
  }
}

const coppia = new Coppia<string, number>("età", 25);
console.log(coppia.getChiave()); // "età"
console.log(coppia.getValore()); // 25

// Mappa generica
class Mappa<K, V> {
  private items: Map<K, V> = new Map();

  set(chiave: K, valore: V): void {
    this.items.set(chiave, valore);
  }

  get(chiave: K): V | undefined {
    return this.items.get(chiave);
  }

  has(chiave: K): boolean {
    return this.items.has(chiave);
  }

  delete(chiave: K): boolean {
    return this.items.delete(chiave);
  }
}

const utenti = new Mappa<number, string>();
utenti.set(1, "Mario");
utenti.set(2, "Anna");

Constraints sui Parametri Tipo

Applicare vincoli ai parametri tipo per limitare i tipi accettabili.

// Constraint con extends
class Repository<T extends { id: number }> {
  private items: T[] = [];

  aggiungi(item: T): void {
    this.items.push(item);
  }

  trovaId(id: number): T | undefined {
    return this.items.find((item) => item.id === id);
  }

  getTutti(): T[] {
    return [...this.items];
  }
}

interface Utente {
  id: number;
  nome: string;
}

interface Prodotto {
  id: number;
  prezzo: number;
}

const repoUtenti = new Repository<Utente>();
repoUtenti.aggiungi({ id: 1, nome: "Mario" });

const repoProdotti = new Repository<Prodotto>();
repoProdotti.aggiungi({ id: 1, prezzo: 99 });

// ERRORE: string non ha proprietà id
// const repoStringhe = new Repository<string>();

Metodi Generici in Classi Generiche

Combinare parametri tipo della classe con parametri tipo dei metodi.

class Collezione<T> {
  private items: T[] = [];

  aggiungi(item: T): void {
    this.items.push(item);
  }

  // Metodo generico aggiuntivo
  mappa<U>(fn: (item: T) => U): U[] {
    return this.items.map(fn);
  }

  // Metodo generico con constraint
  filtraETrasforma<U extends T>(predicate: (item: T) => item is U): U[] {
    return this.items.filter(predicate);
  }

  // Metodo che restituisce classe generica diversa
  converti<U>(): Collezione<U> {
    return new Collezione<U>();
  }
}

const numeri = new Collezione<number>();
numeri.aggiungi(1);
numeri.aggiungi(2);
numeri.aggiungi(3);

const stringhe = numeri.mappa((n) => n.toString()); // string[]
const doppi = numeri.mappa((n) => n * 2); // number[]

Proprietà Generiche

Le proprietà di classe possono usare i parametri tipo della classe.

class Wrapper<T> {
  // Proprietà del tipo generico
  valore: T;

  // Array del tipo generico
  lista: T[] = [];

  // Proprietà opzionale
  default?: T;

  // Proprietà readonly
  readonly immutabile: T;

  constructor(valore: T) {
    this.valore = valore;
    this.immutabile = valore;
  }

  aggiungiALista(item: T): void {
    this.lista.push(item);
  }
}

const wrapper = new Wrapper<string>("hello");
wrapper.aggiungiALista("world");
console.log(wrapper.lista); // ["world"]

Ereditarietà con Generics

Le classi generiche possono estendere altre classi generiche o essere estese.

// Classe base generica
class Container<T> {
  constructor(protected contenuto: T) {}

  getContenuto(): T {
    return this.contenuto;
  }
}

// Estende classe generica specificando tipo
class StringContainer extends Container<string> {
  toUpperCase(): string {
    return this.contenuto.toUpperCase();
  }
}

// Estende classe generica mantenendo generic
class ValidatedContainer<T> extends Container<T> {
  constructor(contenuto: T, private validatore: (item: T) => boolean) {
    super(contenuto);
    if (!validatore(contenuto)) {
      throw new Error("Contenuto non valido");
    }
  }

  setContenuto(nuovoContenuto: T): void {
    if (this.validatore(nuovoContenuto)) {
      this.contenuto = nuovoContenuto;
    }
  }
}

const validated = new ValidatedContainer(42, (n) => n > 0);

// Estende aggiungendo parametri tipo
class PairContainer<T, U> extends Container<T> {
  constructor(contenuto: T, private secondo: U) {
    super(contenuto);
  }

  getSecondo(): U {
    return this.secondo;
  }
}

Static Members in Classi Generiche

I membri statici non possono usare i parametri tipo dell’istanza ma possono avere propri generics.

class Factory<T> {
  // Proprietà istanza usa T
  private item: T;

  constructor(item: T) {
    this.item = item;
  }

  // ERRORE: static non può usare T dell'istanza
  // static staticItem: T;

  // OK: metodo static con proprio generic
  static crea<U>(item: U): Factory<U> {
    return new Factory(item);
  }

  // OK: proprietà static non-generic
  static contatore: number = 0;

  // OK: metodo static con generic indipendente
  static confronta<A, B>(a: A, b: B): boolean {
    return a === b;
  }
}

const factory1 = Factory.crea("hello"); // Factory<string>
const factory2 = Factory.crea(42); // Factory<number>

Default Type Parameters

Specificare tipi default per parametri tipo quando non vengono forniti esplicitamente.

// Parametro tipo con default
class Lista<T = string> {
  private items: T[] = [];

  aggiungi(item: T): void {
    this.items.push(item);
  }

  getTutti(): T[] {
    return [...this.items];
  }
}

// Usa default (string)
const listaDefault = new Lista();
listaDefault.aggiungi("hello");

// Specifica tipo esplicitamente
const listaNumeri = new Lista<number>();
listaNumeri.aggiungi(42);

// Multipli default
class Cache<K = string, V = any> {
  private storage = new Map<K, V>();

  set(key: K, value: V): void {
    this.storage.set(key, value);
  }

  get(key: K): V | undefined {
    return this.storage.get(key);
  }
}

const cache1 = new Cache(); // Cache<string, any>
const cache2 = new Cache<number>(); // Cache<number, any>
const cache3 = new Cache<string, User>(); // Cache<string, User>

Classi Generiche per Data Structures

Implementare strutture dati comuni con generics.

// Stack generico
class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  isEmpty(): boolean {
    return this.items.length === 0;
  }

  size(): number {
    return this.items.length;
  }
}

const stack = new Stack<number>();
stack.push(1);
stack.push(2);
console.log(stack.pop()); // 2

// Queue generico
class Queue<T> {
  private items: T[] = [];

  enqueue(item: T): void {
    this.items.push(item);
  }

  dequeue(): T | undefined {
    return this.items.shift();
  }

  front(): T | undefined {
    return this.items[0];
  }

  isEmpty(): boolean {
    return this.items.length === 0;
  }
}

// LinkedList generico
class Node<T> {
  constructor(public valore: T, public next: Node<T> | null = null) {}
}

class LinkedList<T> {
  private head: Node<T> | null = null;

  aggiungi(valore: T): void {
    const nuovoNodo = new Node(valore);

    if (!this.head) {
      this.head = nuovoNodo;
      return;
    }

    let corrente = this.head;
    while (corrente.next) {
      corrente = corrente.next;
    }
    corrente.next = nuovoNodo;
  }

  toArray(): T[] {
    const risultato: T[] = [];
    let corrente = this.head;

    while (corrente) {
      risultato.push(corrente.valore);
      corrente = corrente.next;
    }

    return risultato;
  }
}

Builder Pattern con Generics

Implementare pattern builder type-safe con classi generiche.

// Builder generico
class QueryBuilder<T> {
  private filters: Array<(item: T) => boolean> = [];
  private sortFn?: (a: T, b: T) => number;
  private limitValue?: number;

  where(predicate: (item: T) => boolean): this {
    this.filters.push(predicate);
    return this;
  }

  orderBy(compareFn: (a: T, b: T) => number): this {
    this.sortFn = compareFn;
    return this;
  }

  limit(n: number): this {
    this.limitValue = n;
    return this;
  }

  execute(data: T[]): T[] {
    let result = data.filter((item) => this.filters.every((f) => f(item)));

    if (this.sortFn) {
      result = result.sort(this.sortFn);
    }

    if (this.limitValue) {
      result = result.slice(0, this.limitValue);
    }

    return result;
  }
}

interface Prodotto {
  nome: string;
  prezzo: number;
  categoria: string;
}

const prodotti: Prodotto[] = [
  { nome: "A", prezzo: 100, categoria: "elettronica" },
  { nome: "B", prezzo: 50, categoria: "libri" },
  { nome: "C", prezzo: 150, categoria: "elettronica" },
];

const risultato = new QueryBuilder<Prodotto>()
  .where((p) => p.categoria === "elettronica")
  .where((p) => p.prezzo > 80)
  .orderBy((a, b) => b.prezzo - a.prezzo)
  .limit(10)
  .execute(prodotti);

Observable/Subject Pattern

Implementare pattern observer con classi generiche.

// Observable generico
class Observable<T> {
  private observers: Array<(value: T) => void> = [];

  subscribe(observer: (value: T) => void): () => void {
    this.observers.push(observer);

    // Restituisce funzione unsubscribe
    return () => {
      const index = this.observers.indexOf(observer);
      if (index > -1) {
        this.observers.splice(index, 1);
      }
    };
  }

  notify(value: T): void {
    this.observers.forEach((observer) => observer(value));
  }
}

// Subject con stato
class BehaviorSubject<T> extends Observable<T> {
  constructor(private currentValue: T) {
    super();
  }

  getValue(): T {
    return this.currentValue;
  }

  next(value: T): void {
    this.currentValue = value;
    this.notify(value);
  }

  subscribe(observer: (value: T) => void): () => void {
    // Emette valore corrente immediatamente
    observer(this.currentValue);
    return super.subscribe(observer);
  }
}

const subject = new BehaviorSubject<number>(0);

subject.subscribe((value) => console.log("Observer 1:", value));
subject.next(1); // Observer 1: 1
subject.next(2); // Observer 1: 2

subject.subscribe((value) => console.log("Observer 2:", value)); // Observer 2: 2
subject.next(3); // Observer 1: 3, Observer 2: 3

Singleton Pattern con Generics

Implementare singleton type-safe con classi generiche.

// Singleton base generico
class Singleton<T> {
  private static instances = new Map<any, any>();

  protected constructor() {}

  static getInstance<U>(this: new () => U): U {
    if (!Singleton.instances.has(this)) {
      Singleton.instances.set(this, new this());
    }
    return Singleton.instances.get(this);
  }
}

class DatabaseConnection extends Singleton<DatabaseConnection> {
  private connected = false;

  connect(): void {
    if (!this.connected) {
      console.log("Connessione al database...");
      this.connected = true;
    }
  }

  query(sql: string): void {
    console.log("Query:", sql);
  }
}

const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();

console.log(db1 === db2); // true

Type Guards in Classi Generiche

Implementare type guards per narrowing in classi generiche.

// Classe con type guard
class Result<T, E = Error> {
  private constructor(private readonly value?: T, private readonly error?: E) {}

  static ok<T, E = Error>(value: T): Result<T, E> {
    return new Result<T, E>(value, undefined);
  }

  static err<T, E = Error>(error: E): Result<T, E> {
    return new Result<T, E>(undefined, error);
  }

  isOk(): this is { value: T } {
    return this.value !== undefined;
  }

  isErr(): this is { error: E } {
    return this.error !== undefined;
  }

  unwrap(): T {
    if (this.isOk()) {
      return this.value;
    }
    throw new Error("Called unwrap on Err value");
  }

  unwrapOr(defaultValue: T): T {
    return this.isOk() ? this.value : defaultValue;
  }
}

const success = Result.ok<number>(42);
const failure = Result.err<number, string>("errore");

if (success.isOk()) {
  console.log(success.unwrap()); // 42
}

if (failure.isErr()) {
  console.log("Errore:", failure.unwrapOr(0)); // 0
}

Conclusioni

Le classi generiche in TypeScript forniscono meccanismo potente per creare componenti riutilizzabili e type-safe che operano su tipi parametrici. Combinando parametri tipo, constraints, ereditarietà, e default types, è possibile modellare strutture dati complesse e pattern architetturali mantenendo type safety completa. L’uso di generics in classi elimina duplicazione di codice, migliora manutenibilità, e permette al compilatore di verificare correttezza delle operazioni, risultando essenziale per librerie, framework, e applicazioni che richiedono astrazione su tipi diversi mantenendo garanzie statiche.