TypeScript Generics descomplicado: da confusão ao domínio total (com padrões do mundo real)
Generics são a feature mais poderosa do sistema de tipos do TypeScript — e a mais mal entendida. Se você já ficou encarando uma assinatura de tipo como <T extends Record<string, unknown>, K extends keyof T> e sentiu o cérebro travar, não tá sozinho.
A real: generics não são complicados. São simplesmente funções pra tipos. Uma vez que isso faz click na cabeça, todo o resto se encaixa. Esse guia vai te levar de "eu mais ou menos entendo generics" pra "consigo escrever bibliotecas type-safe" de uma sentada só.
O que são Generics de verdade (o modelo mental de 60 segundos)
Um generic é uma variável de tipo — um placeholder pra um tipo que vai ser preenchido depois. Assim como um parâmetro de função é um placeholder pra um valor.
Função normal:
function identity(value: string): string { return value; }
Isso só funciona pra strings. E se a gente quiser que funcione pra qualquer tipo?
Sem generics — você perde a informação de tipo:
function identity(value: any): any { return value; } const result = identity("hello"); // result é 'any' — inútil
Com generics — o tipo flui:
function identity<T>(value: T): T { return value; } const result = identity("hello"); // result é 'string' ✅ const num = identity(42); // num é 'number' ✅
T é o parâmetro de tipo genérico. Quando você chama identity("hello"), o TypeScript infere que T = string e leva essa informação até o tipo de retorno. Você escreve a função uma vez, e ela funciona corretamente pra qualquer tipo mantendo type safety.
Esse é o conceito inteiro. Tudo o que vem depois nesse guia se constrói em cima dessa única ideia.
Além do básico: Generic Constraints
Generics sem restrição aceitam qualquer coisa. Nem sempre é o que você quer.
O problema: permissivo demais
function getLength<T>(value: T): number { return value.length; // ❌ Erro: Property 'length' does not exist on type 'T' }
O TypeScript não sabe que T tem uma propriedade length. Poderia ser um number, um boolean, qualquer coisa.
A solução: keyword extends
function getLength<T extends { length: number }>(value: T): number { return value.length; // ✅ Funciona — T tem 'length' garantido } getLength("hello"); // ✅ string tem length getLength([1, 2, 3]); // ✅ array tem length getLength(42); // ❌ Erro: number não tem length
O keyword extends adiciona um constraint — diz pro TypeScript "T deve ser um tipo que tenha pelo menos essas propriedades." Pense nisso como uma interface mínima.
Padrão real: API Response Wrapper
interface ApiResponse<T> { data: T; status: number; timestamp: string; } interface User { id: string; name: string; email: string; } interface Product { id: string; title: string; price: number; } type UserResponse = ApiResponse<User>; // { data: User; status: number; timestamp: string; } type ProductResponse = ApiResponse<Product>; // { data: Product; status: number; timestamp: string; }
Uma interface, infinitamente reutilizável. O campo data é type-safe pra cada caso de uso.
Múltiplos parâmetros de tipo
Generics podem ter múltiplos parâmetros, assim como funções podem ter múltiplos argumentos:
function pair<A, B>(first: A, second: B): [A, B] { return [first, second]; } const result = pair("hello", 42); // [string, number]
Padrão real: Event Emitter type-safe
type EventMap = { userLogin: { userId: string; timestamp: Date }; pageView: { url: string; referrer: string }; purchase: { productId: string; amount: number }; }; class TypedEventEmitter<Events extends Record<string, any>> { private handlers: Partial<{ [K in keyof Events]: Array<(payload: Events[K]) => void>; }> = {}; on<K extends keyof Events>( event: K, handler: (payload: Events[K]) => void ): void { if (!this.handlers[event]) { this.handlers[event] = []; } this.handlers[event]!.push(handler); } emit<K extends keyof Events>(event: K, payload: Events[K]): void { this.handlers[event]?.forEach((handler) => handler(payload)); } } const emitter = new TypedEventEmitter<EventMap>(); emitter.on("userLogin", (payload) => { console.log(payload.userId); // ✅ Autocomplete funciona console.log(payload.timestamp); // ✅ Type-safe }); emitter.on("purchase", (payload) => { console.log(payload.amount); // ✅ number }); emitter.emit("pageView", { url: "/home", referrer: "google.com", }); // ✅ O formato do payload é validado
Esse padrão elimina uma classe inteira de bugs de runtime. O nome do evento e o formato do payload tão vinculados no nível de tipos. Se você renomear um evento ou mudar o payload, o TypeScript pega todo handler quebrado em compile time.
Utility Types genéricos: as ferramentas de poder do TypeScript
O TypeScript vem com vários utility types genéricos que todo dev deveria conhecer. Eles são construídos com os mesmos padrões que você tá aprendendo.
Partial<T> — Tornar todas as propriedades opcionais
interface User { name: string; email: string; age: number; } function updateUser(id: string, updates: Partial<User>): void { // updates pode ter qualquer combinação de propriedades de User } updateUser("123", { name: "Alice" }); // ✅ updateUser("123", { email: "[email protected]" }); // ✅ updateUser("123", { invalid: true }); // ❌ Erro
Pick<T, K> — Selecionar propriedades específicas
type UserPreview = Pick<User, "name" | "email">; // { name: string; email: string; }
Omit<T, K> — Remover propriedades específicas
type CreateUserDto = Omit<User, "id" | "createdAt">; // Tudo de User exceto id e createdAt
Record<K, V> — Criar tipos de objeto
type StatusMap = Record<"active" | "inactive" | "banned", User[]>; // { active: User[]; inactive: User[]; banned: User[]; }
ReturnType<T> — Extrair o tipo de retorno de uma função
function createUser() { return { id: "1", name: "Alice", role: "admin" as const }; } type NewUser = ReturnType<typeof createUser>; // { id: string; name: string; role: "admin" }
Como o Partial funciona por dentro
A implementação real:
type Partial<T> = { [P in keyof T]?: T[P]; };
Isso é um mapped type. Ele itera sobre cada chave P em T, torna cada uma opcional (?), e preserva o tipo de valor original (T[P]). Entender isso te libera pra criar seus próprios utility types.
Tipos condicionais: lógica no nível de tipos
Tipos condicionais permitem escrever lógica if/else no sistema de tipos:
type IsString<T> = T extends string ? "yes" : "no"; type A = IsString<string>; // "yes" type B = IsString<number>; // "no"
Pode parecer acadêmico, mas tipos condicionais resolvem problemas reais.
Padrão real: API Response Unwrapper
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T; type A = UnwrapPromise<Promise<string>>; // string type B = UnwrapPromise<Promise<number>>; // number type C = UnwrapPromise<string>; // string (não é Promise, retorna como tá)
O keyword infer captura um tipo dentro de um padrão. Aqui, ele extrai o tipo interno de um Promise<>.
Padrão real: acesso profundo de propriedades
type NestedValue<T, Path extends string> = Path extends `${infer Key}.${infer Rest}` ? Key extends keyof T ? NestedValue<T[Key], Rest> : never : Path extends keyof T ? T[Path] : never; interface Config { database: { host: string; port: number; credentials: { username: string; password: string; }; }; cache: { ttl: number; }; } type DbHost = NestedValue<Config, "database.host">; // string type DbUser = NestedValue<Config, "database.credentials.username">; // string type CacheTtl = NestedValue<Config, "cache.ttl">; // number
Esse tipo é o que alimenta bibliotecas como o _.get() do Lodash ou a API baseada em paths do tRPC.
Mapped Types: transformar tipos programaticamente
Mapped types permitem criar novos tipos transformando os existentes:
// Tornar todas as propriedades readonly type Readonly<T> = { readonly [P in keyof T]: T[P]; }; // Tornar todas as propriedades nullable type Nullable<T> = { [P in keyof T]: T[P] | null; }; // Tornar todas as propriedades obrigatórias type Required<T> = { [P in keyof T]-?: T[P]; };
Padrão real: tipos pra estado de formulário
interface UserForm { name: string; email: string; bio: string; } // Gerar estado de erro pra cada campo type FormErrors<T> = { [K in keyof T]?: string; }; // Gerar estado de touched pra cada campo type FormTouched<T> = { [K in keyof T]: boolean; }; const errors: FormErrors<UserForm> = { email: "Formato de email inválido", }; const touched: FormTouched<UserForm> = { name: true, email: true, bio: false, };
Uma interface pros dados do formulário, e você deriva o estado de erro e touched automaticamente.
Template Literal Types: manipulação de strings no nível de tipos
O TypeScript consegue manipular strings no sistema de tipos:
type EventName<T extends string> = `on${Capitalize<T>}`; type ClickEvent = EventName<"click">; // "onClick" type FocusEvent = EventName<"focus">; // "onFocus" type SubmitEvent = EventName<"submit">; // "onSubmit"
Padrão real: Route Builder type-safe
type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}` ? Param | ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? Param : never; type Params = ExtractParams<"/users/:userId/posts/:postId">; // "userId" | "postId" function buildUrl<T extends string>( template: T, params: Record<ExtractParams<T>, string> ): string { return Object.entries(params).reduce( (url, [key, value]) => url.replace(`:${key}`, value as string), template as string ); } buildUrl("/users/:userId/posts/:postId", { userId: "123", postId: "456", }); // ✅ buildUrl("/users/:userId/posts/:postId", { userId: "123", // ❌ Erro: falta 'postId' });
O sistema de tipos faz o parse da template string da URL e obriga você a fornecer cada parâmetro.
O operador satisfies: validar tipos sem alargar
Introduzido no TypeScript 4.9, satisfies valida que um valor está em conformidade com um tipo sem mudar o tipo inferido:
type Color = "red" | "green" | "blue"; type ColorMap = Record<Color, string | number[]>; // Sem satisfies — o tipo alarga const colors1: ColorMap = { red: "#ff0000", green: [0, 255, 0], blue: "#0000ff", }; colors1.red.toUpperCase(); // ❌ Erro: pode ser number[] // Com satisfies — tipos literais preservados const colors2 = { red: "#ff0000", green: [0, 255, 0], blue: "#0000ff", } satisfies ColorMap; colors2.red.toUpperCase(); // ✅ TypeScript sabe que é string colors2.green.map((c) => c * 2); // ✅ TypeScript sabe que é number[]
satisfies te dá o melhor dos dois mundos: validação de constraints E inferência precisa de tipos.
Boas práticas com Generics
1. Dê nomes descritivos pros parâmetros de tipo
Letras soltas são OK pra generics simples, mas os complexos merecem nomes:
// ❌ Difícil de ler function merge<A, B, C>(source: A, override: B, defaults: C): A & B & C; // ✅ Bem mais claro function merge< TSource, TOverride, TDefaults, >(source: TSource, override: TOverride, defaults: TDefaults): TSource & TOverride & TDefaults;
2. Não use generics quando não precisa
// ❌ Generic desnecessário — T nunca é usado significativamente function greet<T extends string>(name: T): string { return `Hello, ${name}!`; } // ✅ Use o tipo direto function greet(name: string): string { return `Hello, ${name}!`; }
Se o parâmetro de tipo aparece só uma vez na assinatura, provavelmente você não precisa de um generic.
3. Use valores padrão em parâmetros genéricos
interface ApiResponse<T = unknown> { data: T; status: number; } const response: ApiResponse = { data: null, status: 200 }; const userResponse: ApiResponse<User> = { data: user, status: 200 };
4. Constraint, não assert
// ❌ Usando 'as' pra forçar tipos function getProperty(obj: any, key: string) { return obj[key] as any; } // ✅ Usando generics com constraints function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { name: "Alice", age: 30 }; getProperty(user, "name"); // ✅ Retorna string getProperty(user, "age"); // ✅ Retorna number getProperty(user, "email"); // ❌ Erro: "email" não tá em keyof user
Erros comuns e como corrigir
Erro 1: Over-engineering com generics demais
// ❌ Ilegível e desnecessário type OverEngineered<T extends object, K extends keyof T, V extends T[K]> = { key: K; value: V; original: T; }; // ✅ Versão mais simples que faz a mesma coisa type PropertyEntry<T extends object> = { [K in keyof T]: { key: K; value: T[K]; original: T }; }[keyof T];
Erro 2: Esquecer a distribuição em tipos condicionais com unions
Tipos condicionais se distribuem sobre unions:
type ToArray<T> = T extends any ? T[] : never; type Result = ToArray<string | number>; // string[] | number[] (NÃO é (string | number)[])
Se quiser prevenir a distribuição, envolva os dois lados em uma tupla:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never; type Result = ToArrayNonDist<string | number>; // (string | number)[]
Erro 3: Não usar parâmetros de tipo const
TypeScript 5.0+ suporta parâmetros de tipo const pra preservar tipos literais:
// Sem const — literais alargam function createConfig<T>(config: T) { return config; } const c1 = createConfig({ mode: "production" }); // { mode: string } — alargado pra string // Com const — literais preservados function createConfig<const T>(config: T) { return config; } const c2 = createConfig({ mode: "production" }); // { mode: "production" } — tipo literal preservado ✅
Juntando tudo: um Query Builder type-safe
Bora construir algo real — um query builder type-safe que combina múltiplos padrões genéricos:
interface Schema { users: { id: string; name: string; email: string; age: number; role: "admin" | "user"; }; posts: { id: string; title: string; content: string; authorId: string; published: boolean; }; } type WhereClause<T> = { [K in keyof T]?: T[K] | { gt?: T[K]; lt?: T[K]; eq?: T[K] }; }; class QueryBuilder< TSchema extends Record<string, Record<string, any>>, TTable extends keyof TSchema = keyof TSchema, > { private table: TTable | null = null; private selectedFields: (keyof TSchema[TTable])[] = []; from<T extends keyof TSchema>(table: T): QueryBuilder<TSchema, T> { const qb = new QueryBuilder<TSchema, T>(); (qb as any).table = table; return qb; } select<K extends keyof TSchema[TTable]>( ...fields: K[] ): QueryBuilder<TSchema, TTable> { this.selectedFields = fields; return this; } where(clause: WhereClause<TSchema[TTable]>): QueryBuilder<TSchema, TTable> { return this; } build(): string { const fields = this.selectedFields.length > 0 ? this.selectedFields.join(", ") : "*"; return `SELECT ${fields} FROM ${String(this.table)}`; } } const db = new QueryBuilder<Schema>(); db.from("users") .select("name", "email") .where({ role: "admin", age: { gt: 18 } }); db.from("posts") .select("title", "published") .where({ published: true }); // ❌ Todos esses dão erros de compilação: // db.from("users").select("title"); // 'title' não existe em users // db.from("posts").where({ role: "admin" }); // 'role' não existe em posts
Esse é o poder dos generics. O sistema de tipos garante que você só pode selecionar colunas que existem, filtrar por campos válidos com os tipos corretos, tudo validado em compile time.
Quando usar Generics
Generics brilham quando:
- Você tá escrevendo utilidades reutilizáveis — funções, classes ou tipos usados com múltiplos formatos de dados
- As relações de tipo importam — o tipo de saída depende do tipo de entrada
- Você tá construindo APIs — interfaces públicas onde os consumidores passam seus próprios tipos
- Você quer eliminar
any— generics são quase sempre a alternativa melhor
Pule os generics quando:
- Um tipo concreto resolve — se você só trabalha com
User, tipe comoUser - Você não tá preservando informação de tipo — se
Taparece só no parâmetro mas não no retorno, provavelmente não precisa - A legibilidade sofre — se seus colegas não conseguem entender a assinatura de tipos, simplifique
Conclusão
Generics do TypeScript não são uma feature separada que você parafusa quando as coisas complicam. São o mecanismo fundamental que faz todo o sistema de tipos funcionar. Cada Array<T>, cada Promise<T>, cada Record<K, V> — são todos generics.
O modelo mental é simples: generics são funções pra tipos. Eles recebem inputs de tipo, processam através de constraints e condicionais, e produzem outputs de tipo. Uma vez que você internaliza isso, para de memorizar sintaxe e começa a ver padrões.
Comece com o básico: um parâmetro de tipo, um constraint. Suba pra tipos condicionais e mapped types quando o problema exigir. E sempre se pergunte: "Um tipo concreto resolve aqui?" Se sim, pule o generic. O melhor código no nível de tipos é o código mais simples que preserva a informação que você precisa.
O sistema de tipos do TypeScript não é só um linter. É uma linguagem de programação dentro de uma linguagem de programação. Generics são como você programa ele.
Explore ferramentas relacionadas
Experimente estas ferramentas gratuitas do Pockit