Generics: funções e tipos parametrizados

1. Introdução aos Generics

Imagine que você precisa criar uma função que retorna o próprio elemento recebido. Sem generics, a tentativa mais comum é usar any:

function identity(value: any): any {
  return value;
}

const result = identity("Hello");
// result é 'any' — perdemos todo o tipo!

O problema é claro: ao usar any, sacrificamos a segurança de tipos. O compilador não sabe se o retorno é string, number ou objeto. É aqui que os generics entram em cena.

Generics permitem criar funções, classes e tipos que funcionam com múltiplos tipos, mantendo a segurança e a integridade da tipagem. A sintaxe básica utiliza <T> (convenção para "Type"):

function identity<T>(value: T): T {
  return value;
}

const result = identity("Hello"); // tipo inferido: string
const number = identity(42);      // tipo inferido: number

O compilador TypeScript infere automaticamente o tipo T a partir do argumento passado, eliminando a necessidade de anotações manuais.

2. Funções Genéricas

A declaração de funções genéricas segue o padrão de adicionar <T> antes dos parâmetros:

function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}

const first = firstElement([1, 2, 3]);       // tipo: number | undefined
const firstStr = firstElement(["a", "b"]);   // tipo: string | undefined

Podemos também especificar o tipo explicitamente, embora a inferência automática seja preferível na maioria dos casos:

const explicit = firstElement<string>(["x", "y"]); // explícito, mas redundante

3. Múltiplos Parâmetros de Tipo

Funções podem receber múltiplos parâmetros de tipo para lidar com diferentes tipos de entrada e saída:

function merge<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

const merged = merge({ name: "Alice" }, { age: 30 });
// merged: { name: string } & { age: number }
// Acessos: merged.name (string), merged.age (number)

Outro exemplo prático é a criação de pares tipados:

function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const p = pair("id", 123); // tipo: [string, number]

4. Constraints em Parâmetros de Tipo

Nem todo tipo é adequado para todas as operações. Constraints com extends limitam quais tipos são aceitos:

function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}

getLength("Hello");        // 5 — string tem length
getLength([1, 2, 3]);      // 3 — array tem length
// getLength(123);         // Erro! number não tem length

Constraints também permitem acessar propriedades com segurança:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Bob", age: 25 };
getProperty(user, "name"); // string
getProperty(user, "age");  // number
// getProperty(user, "email"); // Erro! 'email' não é chave de user

5. Tipos Genéricos com Interfaces e Type Aliases

Interfaces genéricas permitem criar estruturas de dados reutilizáveis:

interface Container<T> {
  value: T;
  getValue(): T;
}

const stringContainer: Container<string> = {
  value: "Hello",
  getValue() { return this.value; }
};

const numberContainer: Container<number> = {
  value: 42,
  getValue() { return this.value; }
};

Type aliases genéricos são igualmente poderosos:

type Result<T> = {
  data: T;
  error?: string;
  success: boolean;
};

type ApiResponse = Result<{ id: number; name: string }>;
// ApiResponse: { data: { id: number; name: string }; error?: string; success: boolean }

6. Generics com Classes

Classes genéricas combinam a flexibilidade de tipos com a orientação a objetos. Vamos implementar uma pilha genérica:

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;
  }
}

// Uso prático
const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // 20

const stringStack = new Stack<string>();
stringStack.push("TypeScript");
stringStack.push("Generics");
console.log(stringStack.peek()); // "Generics"

7. Padrões Avançados com Generics

Tipos condicionais permitem criar lógica de tipos baseada em condições:

type IsString<T> = T extends string ? "sim" : "não";

type Test1 = IsString<string>;  // "sim"
type Test2 = IsString<number>;  // "não"

Funções de alta ordem com generics possibilitam composição segura:

function pipe<T, U>(fn1: (arg: T) => U) {
  return {
    then<V>(fn2: (arg: U) => V) {
      return pipe((arg: T) => fn2(fn1(arg)));
    },
    execute(arg: T): U {
      return fn1(arg);
    }
  };
}

const doubleToString = pipe((n: number) => n * 2)
  .then((n) => n.toString());

const result = doubleToString.execute(5); // "10" (string)

keyof com generics para acesso seguro a propriedades:

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(item => item[key]);
}

const users = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" }
];

const names = pluck(users, "name"); // string[]
const ids = pluck(users, "id");     // number[]

8. Boas Práticas e Armadilhas Comuns

Nomeação: Use T, U, V para casos simples. Para contextos mais específicos, use nomes descritivos:

// Ruim
function process<T, U>(a: T, b: U) {}

// Bom
function process<TInput, TOutput>(input: TInput): TOutput {}

Quando evitar generics: Se a função sempre opera com um tipo específico, não force generics. Simplicidade é prioridade:

// Desnecessário
function add<T extends number>(a: T, b: T): T { return (a + b) as T; }

// Melhor
function add(a: number, b: number): number { return a + b; }

Erros comuns:
- Esquecer constraints ao acessar propriedades de tipos genéricos
- Usar tipos muito amplos que permitem operações inseguras
- Confiar em inferência quando o contexto não é claro (especifique explicitamente nesses casos)

// Erro: T pode não ter .length
function logLength<T>(item: T) {
  console.log(item.length); // Erro!
}

// Correto
function logLength<T extends { length: number }>(item: T) {
  console.log(item.length); // OK
}

Generics são uma ferramenta essencial no TypeScript para escrever código reutilizável, flexível e seguro. Dominá-los permite criar abstrações poderosas sem sacrificar a tipagem estática que torna o TypeScript tão valioso.

Referências