Guia Completo de Generics Avançados em TypeScript: Constraints, Defaults e Variância Já leu

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 . Considere um cenário real: você precisa de uma função que retorne a propriedade de um objeto. Nem todo tipo possui essa propriedade, então você limita o generic apenas a tipos que a possuem: 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: Constraints podem ser compostos usando intersecção. Imagine validar que um tipo estende múltiplas interfaces: Constraints com Tipos Primitivos Às vezes você quer garantir que um tipo genérico seja apenas string, number ou boolean. Use com literais: Processado: ${value} 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. Defaults são

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á um Cat onde esperava um Dog.

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.

Referências


Artigos relacionados