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,Vsão apenas convenção de nomenclatura, sem significado especial. Para codebases maiores, nomes mais significativos comoTItem,TResponseserã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 | numberlimita as chaves a apenasstringounumber— porque chaves de objeto não podem ser outros tipos.- O resultado
byStatustem typeRecord<"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 tenhaid, entãoMap<number, T>usaridcomo chave com segurança.- Quando você declara
new Repository<Product>(), todo método retorna tipo exatamenteProduct— IDE autocompletename,priceimediatamente.
Nota: Se você usa
Repositoryapenas 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 deTexcetoid, depois torne todos esses campos opcionais.- Dessa forma,
updateEntitynão permite editarid, 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 Udeixa TypeScript ‘adivinhar’ o type dentro dePromise<...>e atribuir aU.- Você não precisa saber antecipadamente o que
fetchCustomerretorna — o type é inferido automaticamente, se a signature defetchCustomermudar,FetchResultse 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 (comoUnwrapPromise), 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íficaCustomerRepositoryé mais fácil de ler e manter. - Esquecer constraint: escrever
function getProp<T>(obj: T, key: string)e depois acessarobj[key]causará erro de typeanyou erro de index signature. Use<T, K extends keyof T>(obj: T, key: K): T[K]em vez disso. - Usar
anyem vez de generic:function identity(x: any): anyperde 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.