Entendendo Generics em TypeScript
Os generics são um dos recursos mais poderosos do TypeScript, permitindo que você escreva código reutilizável e type-safe. Imagine que você precisa criar uma função que funcione com qualquer tipo de dado, mas sem perder a segurança de tipos que o TypeScript oferece. É exatamente isso que os generics fazem. Eles funcionam como variáveis de tipo, permitindo que você parametrize tipos da mesma forma que parametriza valores em funções.
A sintaxe básica usa a notação com colchetes angulares: <T>. Essa letra T é apenas uma convenção (de "Type"), mas você pode usar qualquer nome. Quando você define um generic, está dizendo: "eu vou trabalhar com algum tipo, mas você vai me dizer qual é no momento de usar". Isso oferece flexibilidade máxima sem sacrificar a segurança.
// Função genérica básica
function identity<T>(value: T): T {
return value;
}
// Usando a função
const numberResult = identity(42); // T é number
const stringResult = identity("hello"); // T é string
const boolResult = identity(true); // T é boolean
console.log(typeof numberResult); // "number"
console.log(typeof stringResult); // "string"
O TypeScript infere o tipo automaticamente com base no argumento passado. Você também pode ser explícito se desejar: identity<string>("hello"). Ambas as abordagens funcionam, mas a inferência automática torna o código mais limpo.
Constraints em Generics
Constraints (restrições) são mecanismos que limitam quais tipos podem ser usados com um generic. Sem constraints, você poderia passar qualquer tipo, o que às vezes não faz sentido. Por exemplo, se você quer trabalhar apenas com objetos que têm uma propriedade específica, precisa de um constraint.
A sintaxe de constraint usa extends: <T extends AlgumaCoisa>. Isso significa que T pode ser qualquer tipo, desde que seja compatível com AlgumaCoisa. Os constraints tornam seu código mais seguro e mais expressivo sobre suas intenções.
// Constraint básico: T deve ter a propriedade 'length'
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
// Funciona com strings, arrays e objetos com length
console.log(getLength("hello")); // 5
console.log(getLength([1, 2, 3])); // 3
console.log(getLength({ length: 10 })); // 10
// Isso daria erro em tempo de compilação:
// getLength(42); // Error: Argument of type 'number' is not assignable
Constraints com Classes e Interfaces
Você pode usar classes e interfaces como constraints também. Isso é especialmente útil quando você quer garantir que o tipo possui métodos específicos ou implementa uma interface.
interface HasId {
id: number;
}
function printId<T extends HasId>(obj: T): void {
console.log(`ID: ${obj.id}`);
}
// Funciona
printId({ id: 1, name: "John" });
printId({ id: 2, email: "test@example.com" });
// Erro: 'id' não existe em number
// printId(123);
Constraints com Tipos Union e Keyof
TypeScript permite constraints mais sofisticados usando tipos union e a palavra-chave keyof. O keyof extrai todas as chaves de um tipo, criando um union delas.
// Constraint: T deve ser uma string ou number
function processValue<T extends string | number>(value: T): string {
return `Valor: ${value}`;
}
console.log(processValue("hello")); // OK
console.log(processValue(42)); // OK
// processValue(true); // Error
// Constraint com keyof: K deve ser uma chave de T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: "Alice", age: 30 };
const name = getProperty(person, "name"); // OK, retorna string
const age = getProperty(person, "age"); // OK, retorna number
// getProperty(person, "email"); // Error: "email" não é chave
Default Types em Generics
Default types (tipos padrão) permitem que você especifique um tipo padrão quando nenhum é fornecido explicitamente. Isso é semelhante aos parâmetros padrão em funções, mas para tipos. Quando você não passa um tipo específico, o padrão é usado automaticamente.
A sintaxe é simples: <T = TipoPadrão>. Isso torna seus generics mais flexíveis, permitindo uso simples para casos comuns enquanto mantém a capacidade de especificar tipos diferentes quando necessário.
// Generic com tipo padrão
function create<T = string>(value?: T): T | undefined {
return value;
}
// Sem argumento de tipo, usa string como padrão
const result1 = create(); // T é string, retorna undefined
const result2 = create("hello"); // T é string
const result3 = create<number>(42); // T explicitamente number
const result4 = create<boolean>(true); // T explicitamente boolean
console.log(result2); // "hello"
console.log(result3); // 42
Default Types em Interfaces e Types
Default types são especialmente úteis em interfaces e types, onde você define estruturas genéricas que serão reutilizadas em muitos lugares.
// Interface genérica com default type
interface ApiResponse<T = any> {
status: number;
data: T;
message: string;
}
// Usar sem especificar T
const response1: ApiResponse = {
status: 200,
data: "anything",
message: "Success"
};
// Usar com T específico
const response2: ApiResponse<{ id: number; name: string }> = {
status: 200,
data: { id: 1, name: "John" },
message: "Success"
};
// Type genérico com default
type Container<T = string> = {
value: T;
isEmpty: boolean;
};
const stringContainer: Container = { value: "hello", isEmpty: false };
const numberContainer: Container<number> = { value: 42, isEmpty: false };
Combinando Constraints e Defaults
A combinação de constraints com default types é poderosa. O default deve ser compatível com o constraint, e o TypeScript validará isso.
// Generic com constraint e default type
interface Entity {
id: number;
}
function processEntity<T extends Entity = { id: number; name: string }>(
entity: T
): number {
return entity.id;
}
// Usando o default
const result1 = processEntity({ id: 1, name: "John" });
// Especificando um tipo diferente (mas ainda compatível com Entity)
const result2 = processEntity<{ id: number; email: string }>({
id: 2,
email: "test@example.com"
});
console.log(result1); // 1
console.log(result2); // 2
Default Types em Múltiplos Generics
Quando você tem múltiplos parâmetros genéricos, cada um pode ter seu próprio default type. Isso oferece muita flexibilidade.
// Múltiplos generics com defaults
interface Paginated<T = any, P = number> {
items: T[];
pageNumber: P;
totalPages: P;
}
// Usar todos os defaults
const page1: Paginated = {
items: ["a", "b", "c"],
pageNumber: 1,
totalPages: 5
};
// Especificar apenas o primeiro
const page2: Paginated<{ id: number; title: string }> = {
items: [{ id: 1, title: "Post 1" }],
pageNumber: 1,
totalPages: 10
};
// Especificar ambos
const page3: Paginated<string, string> = {
items: ["item1", "item2"],
pageNumber: "first",
totalPages: "last"
};
Casos Práticos e Padrões Avançados
Na prática profissional, constraints e defaults trabalham juntos para criar APIs robustas e amigáveis. Vamos ver alguns padrões que você encontrará em código real.
Padrão: Generic Builders
interface Builder<T> {
build(): T;
}
class ObjectBuilder<T extends Record<string, any> = { id: number }> {
private obj: Partial<T> = {};
set<K extends keyof T>(key: K, value: T[K]): this {
this.obj[key] = value;
return this;
}
build(): T {
return this.obj as T;
}
}
// Usar com tipo padrão
const builder1 = new ObjectBuilder()
.set("id", 1)
.build();
// Usar com tipo específico
interface User {
id: number;
name: string;
email: string;
}
const builder2 = new ObjectBuilder<User>()
.set("id", 1)
.set("name", "Alice")
.set("email", "alice@example.com")
.build();
console.log(builder2); // { id: 1, name: "Alice", email: "alice@example.com" }
Padrão: Generic Storage com Validação
interface Validator<T> {
validate(value: unknown): value is T;
}
class TypedStorage<T, V extends Validator<T> = Validator<T>> {
private data: Map<string, T> = new Map();
constructor(private validator: V) {}
set(key: string, value: unknown): boolean {
if (this.validator.validate(value)) {
this.data.set(key, value);
return true;
}
return false;
}
get(key: string): T | undefined {
return this.data.get(key);
}
}
// Validator para números
const numberValidator: Validator<number> = {
validate: (value): value is number => typeof value === "number"
};
const numStorage = new TypedStorage(numberValidator);
numStorage.set("count", 42); // true
numStorage.set("count", "hello"); // false
console.log(numStorage.get("count")); // 42
Padrão: Generic Utilities com Keyof
// Utilitário para filtrar e mapear objetos de forma type-safe
function pickProperties<T, K extends keyof T>(
obj: T,
...keys: K[]
): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach(key => {
result[key] = obj[key];
});
return result;
}
interface Product {
id: number;
name: string;
price: number;
description: string;
}
const product: Product = {
id: 1,
name: "Laptop",
price: 999,
description: "High-performance laptop"
};
// Pegar apenas id e name
const summary = pickProperties(product, "id", "name");
console.log(summary); // { id: 1, name: "Laptop" }
// TypeScript garante que você só pode pedir chaves válidas:
// pickProperties(product, "invalid"); // Error
Conclusão
Você aprendeu que constraints e defaults são ferramentas complementares que tornam seus generics mais seguros e úteis. Constraints garantem que você trabalhe apenas com tipos que fazem sentido para sua lógica, enquanto defaults oferecem convenção sensata para os casos mais comuns, reduzindo a necessidade de sempre especificar tipos explicitamente.
O segundo ponto importante é que a combinação desses dois recursos permite criar APIs genéricas que são simultaneamente flexíveis e seguras. Você consegue escrever código reutilizável que funciona com múltiplos tipos, mas sem perder a validação em tempo de compilação que torna o TypeScript valioso.
Por fim, entenda que esses padrões são encontrados em todas as bibliotecas TypeScript profissionais. Frameworks como NestJS, bibliotecas como Zod e padrões de design moderno usam extensivamente constraints e defaults. Dominando esses conceitos, você consegue não apenas usar essas ferramentas, mas também projetar suas próprias APIs genéricas de forma responsável.