Skip to content

Restrições: extends, keyof e valores-padrão

O básico deixou um incômodo no ar. Dentro de uma função com um T totalmente livre, você quase nada pode fazer com o valor, porque o compilador não sabe nada sobre ele. Tente medir o tamanho de algo genérico:

function tamanho<T>(x: T): number {
return x.length; // erro: a propriedade 'length' não existe no tipo 'T'
}

O erro é justo. T pode ser number, e number não tem length. Como o compilador não sabe qual tipo virá, ele precisa recusar tudo que não vale para todos os tipos possíveis, e quase nada vale para todos. Um T sem restrição é ótimo para carregar um valor de um lado a outro sem tocá-lo, como fazia a identidade, mas inútil quando você precisa olhar dentro dele.

A restrição resolve isso. Escrever T extends Algo diz ao compilador “aceito qualquer T, contanto que ele seja pelo menos um Algo”, e a partir daí você pode usar tudo que um Algo oferece, porque agora há uma garantia. A palavra extends aqui não é herança de classe; leia como “é atribuível a”, ou “se encaixa em”.

function tamanho<T extends { length: number }>(x: T): number {
return x.length; // agora ok: todo T tem, no mínimo, um length numérico
}
tamanho([1, 2, 3]); // ok, arrays têm length
tamanho("olá"); // ok, strings têm length
tamanho(42); // erro: number não satisfaz { length: number }

Vale entender por que não basta escrever x: { length: number } direto, sem generic. A diferença aparece no retorno. Se o objetivo fosse só ler o length, o tipo fixo bastaria. Mas quando a função precisa devolver o que recebeu, o generic preserva o tipo exato, enquanto o tipo fixo o achataria:

// Com generic restrito: devolve o tipo específico que entrou.
function maisComprido<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
const r = maisComprido([1, 2], [1, 2, 3]);
// ^? number[] (o tipo específico sobreviveu)
// Sem generic: o retorno esquece que era um array.
function maisCompridoFrouxo(a: { length: number }, b: { length: number }) {
return a.length >= b.length ? a : b;
}
const r2 = maisCompridoFrouxo([1, 2], [1, 2, 3]);
// ^? { length: number } (virou só "algo com length")

Essa é a lição central da restrição: ela é o meio-termo entre o T livre, que preserva o tipo mas não deixa você tocá-lo, e o tipo fixo, que deixa tocar mas apaga a especificidade. Com extends, você toca e preserva.

A restrição mais útil de todas envolve as chaves de um objeto, e para chegar nela é preciso primeiro dois operadores de tipo que não têm nada de genérico. Vale conhecê-los sobre um tipo concreto antes de misturá-los com parâmetros de tipo, senão a combinação parece mágica.

O operador keyof pega um tipo objeto e devolve a união das suas chaves, como um tipo:

type Pessoa = {
nome: string;
idade: number;
ativo: boolean;
};
type ChaveDePessoa = keyof Pessoa;
// ^? "nome" | "idade" | "ativo"

O acesso indexado faz o caminho complementar: dado um tipo e uma chave, devolve o tipo daquele campo. A sintaxe imita o acesso a propriedade em JavaScript, mas opera sobre tipos:

type TipoDaIdade = Pessoa["idade"];
// ^? number
// A chave também pode ser uma união, e aí o resultado é a união dos tipos.
type Valores = Pessoa[keyof Pessoa];
// ^? string | number | boolean

Esses dois operadores existem por si sós, sem generics. O que os torna poderosos é combiná-los com um parâmetro de tipo, para escrever funções que falam sobre “uma chave qualquer de um objeto qualquer” sem perder a precisão.

Junte extends, keyof e o acesso indexado e você monta a função que lê uma propriedade de um objeto de forma totalmente tipada. Ela precisa de dois parâmetros de tipo amarrados um ao outro:

function pega<T, K extends keyof T>(obj: T, chave: K): T[K] {
return obj[chave];
}
const p = { nome: "Ana", idade: 30 };
const nome = pega(p, "nome");
// ^? string
const idade = pega(p, "idade");
// ^? number
pega(p, "email"); // erro: "email" não é chave de p

Vale ler a assinatura em português, porque ela condensa quase tudo deste capítulo. T é o tipo do objeto. K extends keyof T diz que K é uma das chaves de T, o que garante, antes de rodar, que você só pode pedir chaves que existem: "email" é recusado no editor, não em produção. E o retorno T[K] diz que o tipo devolvido é exatamente o do campo pedido, então pega(p, "idade") sai como number e não como um string | number frouxo. A amarração entre T e K é o que faz a mágica: K não é qualquer string, é qualquer chave daquele T.

Assim como um parâmetro de função pode ter um valor default, um parâmetro de tipo pode ter um tipo default, com a mesma sintaxe do =. Isso deixa o parâmetro opcional no ponto de uso: quem não passa nada recebe o default. O Result do capítulo anterior é um bom lugar para aplicar, já que o erro costuma ser um Error:

type Result<T, E = Error> =
| { ok: true; valor: T }
| { ok: false; erro: E };
type R1 = Result<number>;
// erro é Error, porque E caiu no default
type R2 = Result<number, string>;
// erro é string, porque E foi informado

O default pode inclusive depender de outro parâmetro declarado antes dele, o que serve para casos em que dois tipos coincidem na maioria das vezes:

// Por padrão, os dois lados do par têm o mesmo tipo;
// quem quiser um par heterogêneo informa o segundo.
type Par<A, B = A> = [A, B];
type Coordenada = Par<number>;
// ^? [number, number]
type Entrada = Par<string, number>;
// ^? [string, number]

Por padrão, a inferência “alarga” (widening) os valores para o tipo mais geral: um array literal vira string[], um "pendente" vira string. Na maior parte do tempo isso é o que se quer, mas às vezes você precisa preservar o literal exato, por exemplo para tratar um array como uma tupla de valores fixos. Antes do TypeScript 5.0, o jeito era pedir as const em cada chamada. Desde o 5.0, um parâmetro de tipo const embute esse comportamento na assinatura:

function semConst<T>(x: T): T {
return x;
}
function comConst<const T>(x: T): T {
return x;
}
const a = semConst(["norte", "sul"]);
// ^? string[]
const b = comConst(["norte", "sul"]);
// ^? readonly ["norte", "sul"]

Com const T, quem chama não precisa lembrar de escrever as const: a inferência já preserva os literais e produz uma tupla readonly. É especialmente útil em bibliotecas, onde você quer que o usuário obtenha tipos precisos sem exigir cerimônia dele.

Uma restrição também pode entrar num type guard, aquela função cujo retorno é um predicado x is T e que estreita o tipo de uma variável quando devolve true. Combinar predicado com generic permite escrever guardas reutilizáveis, parametrizados pelo tipo que verificam. Um guard que confirma “isto é um array cujos itens passam por este outro guard”:

function ehArrayDe<T>(
valor: unknown,
guardaDoItem: (item: unknown) => item is T,
): valor is T[] {
return Array.isArray(valor) && valor.every(guardaDoItem);
}
const ehString = (x: unknown): x is string => typeof x === "string";
const dados: unknown = JSON.parse('["a", "b"]');
if (ehArrayDe(dados, ehString)) {
// dentro deste bloco, 'dados' foi estreitado para string[]
dados.map((s) => s.toUpperCase());
}

O T é inferido a partir do guard passado: como ehString prova x is string, o ehArrayDe conclui valor is string[]. Type guards genéricos são a ponte entre a fronteira insegura do programa, onde tudo é unknown (dados de rede, JSON.parse, entrada do usuário), e o interior tipado, e reaparecem quando o assunto for validação.

Com restrições, você deixou de apenas carregar tipos e passou a inspecioná-los e amarrá-los uns aos outros. O próximo capítulo dá o maior salto do curso: tipos que decidem, ramificando conforme a forma do que recebem, e que extraem tipos de dentro de outros. É onde extends reaparece, agora não como restrição, mas como uma pergunta.

Operadores: extends, keyof, acesso indexado T[K], as const Conceitos: restrição, parâmetro de tipo amarrado, valor-padrão de tipo, parâmetro const, widening, type guard genérico, predicado x is T Exemplos: tamanho, maisComprido, pega/getProperty, Result com default, Par, ehArrayDe