Por Que any Faz Você Perder o Tipo que Passou?
Tipe um parâmetro como any e o TypeScript para de verificar tudo o que sai da função:
| |
Você sabe que passou um array de números. O compilador não sabe, porque any faz ele parar de olhar. O tipo de n fica any, então chamar um método de string em um número compila sem problemas e lança TypeError: n.toUpperCase is not a function em tempo de execução.
Um generic nomeia o tipo em vez de descartá-lo:
| |
T é um parâmetro de tipo, um espaço reservado preenchido em cada chamada. firstElement([1, 2, 3]) preenche T com number, o tipo de retorno se torna number, e a chamada errada a .toUpperCase() falha no seu editor em vez de em produção. Você escreve a função uma vez e cada tipo de elemento mantém sua verificação.
O resto deste guia trata de usar isso sem deixar a assinatura mais difícil de ler do que o bug que ela evita.
Como o TypeScript Infere o Parâmetro de Tipo?
Você não escreveu firstElement<number>([1, 2, 3]) acima, e raramente precisa. O TypeScript compara o argumento com a forma declarada do parâmetro (T[]) e resolve T. É a mesma inferência que tipa const x = 5 como number, aplicada aos parâmetros de tipo.
Sem nada de onde inferir, T cai para unknown:
| |
Quando a chamada não tem nenhum valor a partir do qual inferir, passe você mesmo o argumento de tipo entre colchetes angulares.
A inferência também pode dar errado quando dois argumentos alimentam o mesmo T e divergem. O TypeScript escolhe um candidato e aponta o outro argumento como o problema, o que pode produzir um erro que nomeia um tipo que você nunca escreveu:
| |
Quando uma chamada genérica dá um erro confuso ou um tipo inesperado, passe o mouse sobre a chamada no seu editor e veja o que foi inferido para T. Isso geralmente explica o problema mais rápido do que ler a mensagem.
Como Restringir um Generic com extends e keyof?
Um T sem restrição pode ser qualquer coisa, então dentro da função você só pode fazer o que todo tipo suporta, o que é quase nada além de passar o valor adiante. A maioria das funções genéricas reais precisa assumir algo sobre T, e extends é a forma de dizer isso:
| |
K extends keyof T limita K aos nomes das propriedades do objeto que você passar, e T[K] lê o tipo dessa propriedade. A lista de chaves válidas vem do objeto no ponto de chamada, não de uma union fixada de antemão. Um erro de digitação como "pay" se torna um erro de compilação em vez de um undefined silencioso.
A restrição também pode ser uma forma simples:
| |
Tanto strings quanto arrays têm uma length, então ambos passam. Um número não, e a chamada é rejeitada antes mesmo de o corpo da função ser executado. Como o tipo de retorno é T e não { length: number }, logLength("hello") ainda devolve uma string.
Como Funcionam Interfaces e Classes Genéricas?
Uma interface genérica é uma forma com um parâmetro de tipo:
| |
Uma classe genérica compartilha seu parâmetro de tipo entre todos os campos e métodos:
| |
new Stack<number>() e new Stack<string>() ficam totalmente tipados, e a classe existe uma única vez. O mesmo padrão aparece em qualquer lugar onde você use dependency injection: você escreve um Repository<T> uma vez, e depois injeta Repository<User> e Repository<Order> onde são necessários, em vez de manter uma classe quase idêntica para cada entidade.
Vários parâmetros de tipo funcionam da mesma forma, e cada um pode ter sua própria restrição:
| |
Dois parâmetros também aparecem em Result<T, E>, que carrega lado a lado um valor de sucesso e um tipo de erro. Se você está decidindo se funções devem lançar exceções ou devolver as falhas como dados, exceptions vs valores de erro detalha essa escolha.
Como Funcionam os Parâmetros de Tipo Padrão?
Um parâmetro de tipo pode ter um valor padrão, como um parâmetro de função com um valor padrão:
| |
A inferência ainda vence quando há um argumento a partir do qual inferir, como mostra raw. O padrão se aplica quando nada preenche T, por exemplo quando você escreve ApiResponse como anotação de tipo sem argumento de tipo. Isso torna os padrões úteis em wrappers como tipos de resposta e payloads de eventos, onde muitos pontos de chamada não se importam com o tipo exato dos dados. Use unknown como padrão, não any: ainda assim força uma verificação antes que alguém leia data.
Quando Usar um Union Type em Vez de um Generic?
Faça uma pergunta antes de adicionar <T>: o tipo de retorno depende do que foi passado? Se a função retorna sempre o mesmo tipo independentemente da entrada, uma union basta e um generic não acrescenta nada:
| |
firstElement, getProperty e Stack<T> são diferentes. Cada um retorna algo cujo tipo é decidido pelo que entrou, e esse vínculo entre entrada e saída é o que um generic traz. Se você conseguir escrever a função com uma union sem perder nada, use a union.
Acumular parâmetros de tipo porque uma função poderia ser genérica custa legibilidade e não traz nada. Um helper com três parâmetros de tipo que só é chamado com string e number é mais difícil de ler do que dois overloads. Adicione <T> quando um chamador real precisa que um tipo flua, e remova quando cada ponto de chamada fixa o mesmo tipo concreto.
Por Que Você Não Pode Usar T em Tempo de Execução?
Os parâmetros de tipo são apagados em tempo de compilação. O JavaScript que roda não tem nenhum T, então você não pode construir um, checar instanceof T, nem ramificar com base nele:
| |
Se uma função precisa construir um T ou inspecioná-lo em tempo de execução, passe um valor real como argumento: um construtor, um schema, ou uma tag de tipo. Generics são um contrato em tempo de compilação, não uma proteção em tempo de execução, então os dados que chegam de fora do seu programa (uma resposta de API, JSON.parse) ainda precisam ser validados mesmo quando tipados como ApiResponse<User>.
Para ver cada erro deste guia com suas próprias mãos, instale o TypeScript com o que seu projeto usar entre npm, Yarn ou pnpm, cole os exemplos em um arquivo .ts e execute npx tsc --noEmit --strict. Depois abra o any mais usado no seu próprio código e verifique se um <T> faria o tipo fluir.