Constraints em generics com extends

1. O que são Constraints em Generics?

Constraints em generics são mecanismos que permitem restringir quais tipos podem ser utilizados como argumento para um parâmetro genérico. A palavra-chave extends é usada para definir esses limites, garantindo que apenas tipos que satisfaçam determinada condição sejam aceitos.

Sem constraints, um parâmetro genérico aceita qualquer tipo:

function obterTamanho<T>(valor: T): number {
    // Erro! TypeScript não sabe se 'valor' tem propriedade 'length'
    return valor.length;
}

Com constraints, podemos garantir operações seguras:

function obterTamanho<T extends { length: number }>(valor: T): number {
    // Agora TypeScript sabe que 'valor' tem 'length'
    return valor.length;
}

obterTamanho("texto"); // 5
obterTamanho([1, 2, 3]); // 3
obterTamanho(123); // Erro! number não tem length

A principal diferença é que constraints transformam generics de "qualquer tipo" para "tipos que possuem características específicas", permitindo acesso seguro a propriedades e métodos.

2. Sintaxe Básica de Constraints

A sintaxe fundamental é T extends TipoBase, onde TipoBase define o limite do que é aceito:

function exibirValor<T extends string | number>(valor: T): string {
    return `Valor: ${valor}`;
}

exibirValor("Olá"); // "Valor: Olá"
exibirValor(42);    // "Valor: 42"
exibirValor(true);  // Erro! boolean não é string | number

Constraints com tipos primitivos são úteis para operações matemáticas ou de string:

function somar<T extends number>(a: T, b: T): T {
    return (a + b) as T;
}

console.log(somar(10, 20)); // 30
console.log(somar("10", "20")); // Erro! string não é number

3. Constraints com Interfaces e Tipos de Objetos

Quando trabalhamos com objetos, podemos usar interfaces para definir constraints mais específicas:

interface Identificavel {
    id: number;
    nome: string;
}

function exibirInfo<T extends Identificavel>(item: T): string {
    return `${item.nome} (ID: ${item.id})`;
}

const usuario = { id: 1, nome: "Maria", email: "maria@email.com" };
const produto = { id: 100, nome: "Notebook", preco: 2500 };

console.log(exibirInfo(usuario)); // "Maria (ID: 1)"
console.log(exibirInfo(produto)); // "Notebook (ID: 100)"
console.log(exibirInfo({ id: "abc", nome: "Teste" })); // Erro! id precisa ser number

Constraints permitem acessar propriedades de forma segura dentro de funções genéricas:

interface ComTamanho {
    tamanho: number;
}

function maiorQue<T extends ComTamanho>(a: T, b: T): T {
    return a.tamanho > b.tamanho ? a : b;
}

const arr1 = [1, 2, 3];
const arr2 = [4, 5];
console.log(maiorQue(arr1, arr2)); // [1, 2, 3] (tamanho 3 > 2)

4. Constraints com Classes e Herança

Classes podem servir como constraints, garantindo que tipos tenham métodos e propriedades específicos:

class Animal {
    constructor(public nome: string) {}

    emitirSom(): string {
        return "Som genérico";
    }
}

class Cachorro extends Animal {
    emitirSom(): string {
        return "Au au!";
    }
}

class Gato extends Animal {
    emitirSom(): string {
        return "Miau!";
    }
}

function apresentar<T extends Animal>(animal: T): string {
    return `${animal.nome} diz: ${animal.emitirSom()}`;
}

console.log(apresentar(new Cachorro("Rex"))); // "Rex diz: Au au!"
console.log(apresentar(new Gato("Mimi")));   // "Mimi diz: Miau!"

Constraints com classes permitem polimorfismo seguro:

class Repositorio<T extends { id: number }> {
    private itens: T[] = [];

    adicionar(item: T): void {
        this.itens.push(item);
    }

    buscarPorId(id: number): T | undefined {
        return this.itens.find(item => item.id === id);
    }
}

interface Produto {
    id: number;
    nome: string;
    preco: number;
}

const repo = new Repositorio<Produto>();
repo.adicionar({ id: 1, nome: "Mouse", preco: 50 });
console.log(repo.buscarPorId(1)); // { id: 1, nome: "Mouse", preco: 50 }

5. Constraints com Union Types e Type Guards

Union types combinados com type guards oferecem flexibilidade adicional:

function processar<T extends string | string[]>(dado: T): number {
    if (Array.isArray(dado)) {
        // Type guard: TypeScript sabe que é string[]
        return dado.length;
    }
    // TypeScript sabe que é string
    return dado.length;
}

console.log(processar("TypeScript"));      // 10
console.log(processar(["a", "b", "c"]));   // 3

Exemplo mais complexo com múltiplos tipos:

type Resposta = { sucesso: true; dados: unknown } | { sucesso: false; erro: string };

function tratarResposta<T extends Resposta>(resposta: T): string {
    if (resposta.sucesso) {
        return `Operação bem-sucedida: ${JSON.stringify(resposta.dados)}`;
    }
    return `Erro: ${resposta.erro}`;
}

console.log(tratarResposta({ sucesso: true, dados: { id: 1 } }));
console.log(tratarResposta({ sucesso: false, erro: "Não encontrado" }));

6. Constraints Múltiplas e Aninhadas

Constraints podem ser combinadas usando o operador & (intersection):

interface SerVivo {
    respirar(): void;
}

interface Trabalhador {
    trabalhar(): string;
}

function realizarAtividades<T extends SerVivo & Trabalhador>(entidade: T): string {
    entidade.respirar();
    return entidade.trabalhar();
}

class Humano implements SerVivo, Trabalhador {
    respirar(): void {
        console.log("Respirando...");
    }

    trabalhar(): string {
        return "Trabalhando...";
    }
}

const pessoa = new Humano();
console.log(realizarAtividades(pessoa)); // "Trabalhando..."

Constraints aninhadas permitem dependências entre parâmetros genéricos:

function parear<T extends U, U extends { id: number }>(item: T, base: U): T {
    return { ...item, id: base.id };
}

interface Base {
    id: number;
}

interface Detalhes extends Base {
    nome: string;
    descricao: string;
}

const detalhes: Detalhes = { id: 1, nome: "Produto", descricao: "Descrição" };
const resultado = parear(detalhes, { id: 100 });
console.log(resultado); // { id: 100, nome: "Produto", descricao: "Descrição" }

7. Erros Comuns e Boas Práticas

Erro comum: não respeitar a constraint

interface ComNome {
    nome: string;
}

function saudacao<T extends ComNome>(obj: T): string {
    return `Olá, ${obj.nome}!`;
}

saudacao({ nome: "João" }); // OK
saudacao({ idade: 30 }); // Erro! 'idade' não tem 'nome'

Evitar uso excessivo de any:

// Ruim: perde segurança de tipos
function processarRuim<T>(dado: T): any {
    return (dado as any).nome;
}

// Bom: usa constraint adequada
function processarBom<T extends { nome: string }>(dado: T): string {
    return dado.nome;
}

Boas práticas para constraints reutilizáveis:

// Definir interfaces base
interface Serializavel {
    toJSON(): string;
}

interface Validador {
    validar(): boolean;
}

// Constraints reutilizáveis
type Salvavel = Serializavel & Validador;

function salvar<T extends Salvavel>(item: T): boolean {
    if (!item.validar()) {
        return false;
    }
    localStorage.setItem("dados", item.toJSON());
    return true;
}

class Usuario implements Salvavel {
    constructor(private nome: string) {}

    toJSON(): string {
        return JSON.stringify({ nome: this.nome });
    }

    validar(): boolean {
        return this.nome.length > 0;
    }
}

const usuario = new Usuario("Ana");
console.log(salvar(usuario)); // true

Dicas finais:
- Prefira constraints específicas a any para manter a segurança de tipos
- Use interfaces para definir contratos claros
- Combine constraints com type guards para lógicas condicionais
- Mantenha constraints simples e focadas em comportamentos necessários

Constraints com extends são fundamentais para criar código TypeScript seguro, reutilizável e expressivo, permitindo que generics mantenham sua flexibilidade sem sacrificar a segurança de tipos.

Referências