Constraints: Limitando Tipos Generics
Os constraints definem quais tipos podem ser passados como argumentos para um generic. Sem eles, você trabalha com qualquer tipo, perdendo a segurança do sistema de tipos. Um constraint é declarado com a palavra-chave extends.
Considere um cenário real: você precisa de uma função que retorne a propriedade length de um objeto. Nem todo tipo possui essa propriedade, então você limita o generic apenas a tipos que a possuem:
interface HasLength {
length: number;
}
function getLength<T extends HasLength>(item: T): number {
return item.length;
}
getLength("hello"); // ✓ string tem length
getLength([1, 2, 3]); // ✓ array tem length
getLength({ length: 5 }); // ✓ objeto com length funciona
// getLength(42); // ✗ Erro: number não tem length
Você também pode constrainar usando tipos de propriedades específicas. Por exemplo, uma função que copia apenas propriedades de um objeto para outro:
function copyProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Ana", age: 30 };
const name = copyProperty(user, "name"); // ✓ type-safe
// copyProperty(user, "email"); // ✗ Erro: "email" não existe
Constraints podem ser compostos usando intersecção. Imagine validar que um tipo estende múltiplas interfaces:
interface Named { name: string; }
interface Aged { age: number; }
function createPerson<T extends Named & Aged>(data: T): T {
return { ...data, updatedAt: new Date() };
}
Constraints com Tipos Primitivos
Às vezes você quer garantir que um tipo genérico seja apenas string, number ou boolean. Use extends com literais:
function processValue<T extends string | number>(value: T): string {
return `Processado: ${value}`;
}
processValue("texto"); // ✓
processValue(42); // ✓
// processValue(true); // ✗ Erro
Defaults: Valores Padrão para Generics
Generics podem ter valores padrão, evitando que você sempre passe todos os argumentos de tipo. Isso melhora a ergonomia da API.
interface Repository<T = unknown> {
items: T[];
add(item: T): void;
getAll(): T[];
}
// Sem especificar tipo, T é unknown
const genericRepo: Repository = {
items: [],
add: (item) => {},
getAll: () => []
};
// Com tipo específico
interface User { id: number; name: string; }
const userRepo: Repository<User> = {
items: [],
add: (user) => {},
getAll: () => []
};
Defaults são especialmente úteis em componentes React TypeScript. Imagine um componente de lista reutilizável:
interface ListProps<T = string, K extends keyof T = keyof T> {
items: T[];
keyExtractor: (item: T) => T[K];
renderItem: (item: T) => React.ReactNode;
}
// Funciona com tipo padrão
const stringList = (props: ListProps) => <div />;
// Ou com tipo customizado
interface Product { id: number; title: string; }
const productList = (props: ListProps<Product>) => <div />;
Você pode combinar constraints com defaults. O tipo padrão deve satisfazer o constraint:
interface Config<T extends string | number = string> {
value: T;
validate(input: unknown): input is T;
}
const stringConfig: Config = {
value: "default",
validate: (x): x is string => typeof x === "string"
};
const numberConfig: Config<number> = {
value: 42,
validate: (x): x is number => typeof x === "number"
};
Variância: Covariância, Contravariância e Invariância
Variância define como tipos genéricos se relacionam em hierarquias de herança. Este é o tema mais avançado e frequentemente mal compreendido.
Covariância (out)
Um tipo genérico é covariante quando você pode atribuir um subtipo onde um supertipo é esperado. Arrays TypeScript são covariantes:
class Animal { move() {} }
class Dog extends Animal { bark() {} }
const animals: Animal[] = [];
const dogs: Dog[] = [new Dog()];
// Covariância: Dog[] é atribuível a Animal[]
const list: Animal[] = dogs; // ✓ Funciona
Cuidado: Isto cria um problema de segurança. Se você adicionar um
Catà lista, terá umCatonde esperava umDog.
Generics custom são invariantes por padrão, mas você pode declarar covariância explicitamente com out:
interface Producer<out T> {
produce(): T;
}
class DogProducer implements Producer<Dog> {
produce(): Dog { return new Dog(); }
}
// Covariância: DogProducer pode ser atribuído a Producer<Animal>
const animalProducer: Producer<Animal> = new DogProducer(); // ✓
const animal = animalProducer.produce(); // type: Animal
Contravariância (in)
Um tipo genérico é contravariante quando você pode usar um supertipo onde um subtipo é esperado. Funções de callback são contravariantes no tipo de parâmetro:
interface Consumer<in T> {
consume(item: T): void;
}
class AnimalConsumer implements Consumer<Animal> {
consume(animal: Animal) { console.log("Consumindo animal"); }
}
// Contravariância: AnimalConsumer pode ser atribuído a Consumer<Dog>
const dogConsumer: Consumer<Dog> = new AnimalConsumer(); // ✓
dogConsumer.consume(new Dog()); // Funciona porque Dog é Animal
Invariância
Sem in ou out, o tipo é invariante: você não pode substituir nem por subtipos nem por supertipos. A maioria dos generics são invariantes:
interface Container<T> {
get(): T;
set(value: T): void;
}
const animalContainer: Container<Animal> = null!;
const dogContainer: Container<Dog> = null!;
// animalContainer = dogContainer; // ✗ Erro: Invariante
// dogContainer = animalContainer; // ✗ Erro: Invariante
Invariância é mais segura porque impede leitura e escrita inseguras. Use in e out apenas quando apropriado.
Conclusão
Generics avançados em TypeScript capacitam você a escrever código type-safe e reutilizável em escala. Constraints limitam possibilidades mantendo flexibilidade, defaults reduzem boilerplate sem sacrificar clareza, e variância controla substituição de tipos em hierarquias complexas. Domine estes três pilares e você estará no nível sênior do sistema de tipos TypeScript. Na prática, comece com constraints, adicione defaults quando sentir dor, e explore variância apenas em APIs públicas avançadas.