TypeScript Generics através de exemplos práticos

1. Visão geral

Generics é uma ferramenta que ajuda você a escrever código reutilizável mantendo a type safety. Em vez de escrever UserRepository, OrderRepository, ProductRepository repetidamente, você escreve um Repository<T> compartilhado. Este artigo passa por exemplos práticos: generic function, generic class, constraints, multiple type parameters e como combiná-los com utility types. Depois de ler, você estará confiante em aplicar generics nas camadas de service, repository e API response no seu projeto TypeScript.

2. O que são generics e por que precisa deles

Generics permitem que você “parametrize” o type, assim como uma função recebe parâmetros, mas no nível de type.

// Sem generics: precisa reescrever para cada type
function firstString(arr: string[]): string {
  return arr[0];
}
function firstNumber(arr: number[]): number {
  return arr[0];
}

// Com generics: uma função para todo type
function first<T>(arr: T[]): T {
  return arr[0];
}

const name = first(["Alice", "Bob"]); // T = string, inferido automaticamente
const age = first([30, 25]);          // T = number

<T> é um type parameter — uma variável que representa o type real que será passado na chamada. TypeScript infere automaticamente T baseado no argumento, você não precisa especificar manualmente.

Nota: T, U, K, V são apenas convenção de nomenclatura, sem significado especial. Para codebases maiores, nomes mais significativos como TItem, TResponse serão muito mais fáceis de ler.

3. Generic function na prática: função groupBy

Um exemplo comum ao processar dados: agrupar objetos por um campo.

interface Order {
  id: number;
  status: "pending" | "paid" | "cancelled";
  customerId: number;
}

function groupBy<T, K extends string | number>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  const result = {} as Record<K, T[]>;
  for (const item of items) {
    const key = keyFn(item);
    (result[key] ??= []).push(item);
  }
  return result;
}

const orders: Order[] = [
  { id: 1, status: "paid", customerId: 10 },
  { id: 2, status: "pending", customerId: 11 },
  { id: 3, status: "paid", customerId: 10 },
];

const byStatus = groupBy(orders, (o) => o.status);
  • T é o type do item no array (Order).
  • K extends string | number limita as chaves a apenas string ou number — porque chaves de objeto não podem ser outros tipos.
  • O resultado byStatus tem type Record<"pending" | "paid" | "cancelled", Order[]>, TypeScript sabe exatamente quais chaves podem aparecer.
byStatus.paid       -> Order[] (2 elementos)
byStatus.pending    -> Order[] (1 elemento)
byStatus.cancelled  -> Order[] (pode ser undefined se strict)

4. Generic constraints com extends

Constraint limita T a satisfazer uma forma específica, permitindo que você acesse propriedades com segurança dentro da generic function.

interface HasId {
  id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}

interface Customer extends HasId {
  name: string;
  email: string;
}

const customers: Customer[] = [
  { id: 1, name: "Ana", email: "ana@example.com" },
  { id: 2, name: "Bruno", email: "bruno@example.com" },
];

const found = findById(customers, 1); // Customer | undefined

Se você não tivesse extends HasId, a linha item.id reportaria erro porque TypeScript não saberia se T tem a propriedade id.

error TS2339: Property 'id' does not exist on type 'T'.

Este é o erro mais comum quando você está aprendendo generics: esquecer o constraint e depois acessar propriedades aleatoriamente em T.

5. Generic class: exemplo Repository<T>

Este é o caso de uso clássico — um repository generic para múltiplas entidades.

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();

  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }

  findById(id: number): T | undefined {
    return this.items.get(id);
  }

  findAll(): T[] {
    return Array.from(this.items.values());
  }

  deleteById(id: number): boolean {
    return this.items.delete(id);
  }
}

interface Product extends Entity {
  name: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.save({ id: 1, name: "Teclado", price: 500_000 });
productRepo.save({ id: 2, name: "Mouse", price: 200_000 });

const all = productRepo.findAll(); // Product[]
  • Repository<T extends Entity> garante que toda entidade gerenciada tenha id, então Map<number, T> usar id como chave com segurança.
  • Quando você declara new Repository<Product>(), todo método retorna tipo exatamente Product — IDE autocomplete name, price imediatamente.

Nota: Se você usa Repository apenas para uma única entidade em todo o projeto, generics é over-engineering. Escreva a class específica primeiro, generalize apenas quando tiver ≥ 2 lugares usando a mesma lógica.

6. Multiple type parameters: exemplo ApiResponse<TData, TError>

Quando precisa parametrizar mais de um type, você usa múltiplos type parameters simultaneamente.

interface ApiResponse<TData, TError = string> {
  success: boolean;
  data?: TData;
  error?: TError;
}

function fetchSuccess<T>(data: T): ApiResponse<T> {
  return { success: true, data };
}

function fetchFailure<T, E = string>(error: E): ApiResponse<T, E> {
  return { success: false, error };
}

interface User {
  id: number;
  username: string;
}

const okResponse: ApiResponse<User> = fetchSuccess({ id: 1, username: "nonladev" });

const errResponse: ApiResponse<User, { code: number; message: string }> = fetchFailure({
  code: 404,
  message: "Usuário não encontrado",
});

TError = string na linha de declaração de interface é um default generic type — se você não passar o segundo type, TypeScript usa automaticamente string. É por isso que ApiResponse<User> ainda é válido tendo apenas 1 type argument.

7. Combinando generics com utility types

Utility types como Partial, Pick, Omit são na verdade generic types predefinidos no TypeScript. Combiná-los com seus próprios generics é muito eficaz para funções de atualização.

interface Customer {
  id: number;
  name: string;
  email: string;
  vip: boolean;
}

function updateEntity<T extends { id: number }>(
  original: T,
  patch: Partial<Omit<T, "id">>
): T {
  return { ...original, ...patch };
}

const customer: Customer = { id: 1, name: "Ana", email: "ana@a.com", vip: false };

const updated = updateEntity(customer, { vip: true });
// updated: Customer { id: 1, name: "Ana", email: "ana@a.com", vip: true }
  • Partial<Omit<T, "id">> significa: pegue todos os campos de T exceto id, depois torne todos esses campos opcionais.
  • Dessa forma, updateEntity não permite editar id, e o chamador só precisa passar os campos que quer alterar — muito parecido com o padrão PATCH em REST API.

Se você tentar passar { id: 2 } para patch, TypeScript reportará erro imediatamente porque id foi removido pelo Omit do type permitido:

error TS2345: Object literal may only specify known properties,
and 'id' does not exist in type 'Partial<Omit<Customer, "id">>'.

8. Generic com conditional type: quando precisar de infer

Um passo mais avançado: extrair o tipo de retorno de uma função automaticamente, evitando duplicação manual.

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

async function fetchCustomer(id: number): Promise<Customer> {
  return { id, name: "Ana", email: "ana@a.com", vip: false };
}

type FetchResult = UnwrapPromise<ReturnType<typeof fetchCustomer>>;
// FetchResult = Customer, não Promise<Customer>
  • infer U deixa TypeScript ‘adivinhar’ o type dentro de Promise<...> e atribuir a U.
  • Você não precisa saber antecipadamente o que fetchCustomer retorna — o type é inferido automaticamente, se a signature de fetchCustomer mudar, FetchResult se atualiza automaticamente.

Nota: Conditional type com infer é muito poderoso mas pode deixar o código difícil de ler. Use apenas quando precisar de um type utility reutilizável muitas vezes (como UnwrapPromise), não abuse para lógica de uso único.

9. Escrever testes para generic function

Generics afetam type-checking na compilação, mas o comportamento em tempo de execução ainda precisa de testes normais. Instale Vitest:

<!-- package.json (seção dependencies, não é pom.xml) -->
npm install -D vitest typescript
// repository.test.ts
import { describe, it, expect } from "vitest";

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();
  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }
  findById(id: number): T | undefined {
    return this.items.get(id);
  }
}

interface Product extends Entity {
  name: string;
}

describe("Repository<Product>", () => {
  it("salva e encontra o produto correto por id", () => {
    const repo = new Repository<Product>();
    repo.save({ id: 1, name: "Teclado" });

    const found = repo.findById(1);

    expect(found).toBeDefined();
    expect(found?.name).toBe("Teclado");
  });

  it("retorna undefined quando id não existe", () => {
    const repo = new Repository<Product>();

    const found = repo.findById(999);

    expect(found).toBeUndefined();
  });
});

Execute npx vitest run, resultado esperado:

✓ Repository<Product> > salva e encontra o produto correto por id
✓ Repository<Product> > retorna undefined quando id não existe

Test Files  1 passed (1)
     Tests  2 passed (2)

Este teste não verifica o type (o compilador TypeScript já fez isso na compilação), mas verifica a lógica em tempo de execução da class generic — garantindo que Map funciona corretamente independentemente de qual T é.

10. Erros comuns e quando não usar generics

  • Generic excessivo (over-generic): criar Repository<T> mas o projeto inteiro tem apenas uma entidade. Neste caso, a class específica CustomerRepository é mais fácil de ler e manter.
  • Esquecer constraint: escrever function getProp<T>(obj: T, key: string) e depois acessar obj[key] causará erro de type any ou erro de index signature. Use <T, K extends keyof T>(obj: T, key: K): T[K] em vez disso.
  • Usar any em vez de generic: function identity(x: any): any perde completamente type safety — TypeScript não reportará nenhum erro. Sempre prefira <T>(x: T): T.
  • Generic com muitos type parameters: se uma função tem 4-5 type parameters, o código fica muito difícil de ler. Considere consolidar os types em uma única interface de configuração.
// Difícil de ler
function merge<A, B, C, D>(a: A, b: B, c: C, d: D) { /* ... */ }

// Mais fácil de ler
interface MergeInput<TBase, TExtra> {
  base: TBase;
  extra: TExtra;
}
function merge<TBase, TExtra>(input: MergeInput<TBase, TExtra>) { /* ... */ }

Nota: Se um type parameter é usado apenas uma vez na assinatura inteira da função, provavelmente você não precisa de generic — um type específico ou unknown é suficiente.

11. Conclusão

Generics ajudam você a escrever código reutilizável sem sacrificar type safety: um Repository<T>, um ApiResponse<TData, TError> em vez de dezenas de classes/interfaces duplicadas. A chave é dominar três coisas — type parameter básico, constraints com extends e como combinar com utility types disponíveis (Partial, Omit, Pick). Não generalize tudo desde o início; extraia generics apenas quando você vê lógica duplicada em pelo menos dois lugares diferentes no seu codebase.

typescriptgenericsjavascripttype-safetyfrontend