Type guards: ensinar o compilador a acompanhar o raciocínio
O TypeScript segue raciocínios simples sozinho. Um if que elimina o null faz com que, da chaveta para dentro, o valor deixe de ser nulo — e isso chama-se estreitamento.
function saudar(nome: string | null) {
if (nome === null) return;
// aqui dentro, nome é string — sem ninguém o dizer
return nome.toUpperCase();
}O problema aparece quando a verificação deixa de caber numa expressão, e passa para uma função. Aí o compilador perde o fio.
1. A função que parte o raciocínio
function temValor(x: string | null) {
return x !== null;
}
function saudar(nome: string | null) {
if (temValor(nome)) {
return nome.toUpperCase(); // erro: nome ainda pode ser null
}
}E tem razão em queixar-se: pelo que a assinatura diz, aquela função devolve um boolean — não diz sobre o quê. Qualquer função que devolva true serviria.
2. A forma de o dizer
Troca-se o tipo de retorno por um predicado:
function temValor(x: string | null): x is string {
return x !== null;
}Aquele x is string é uma promessa ao compilador: «quando eu devolver verdadeiro, podes contar que o argumento é desta forma». Com ela, o estreitamento volta a funcionar do outro lado da chamada.
O caso onde isto rende mais é o que este site usa para limpar uma lista:
const usados = tags
.map((t) => t.hue)
.filter((h): h is number => h !== null);
// usados: number[] — e não (number | null)[]Sem o predicado, o filter devolveria (number | null)[]. O compilador sabe que filtrámos, mas não sabe porquê — e com o predicado passa a saber.
3. A responsabilidade que vem junto
Um predicado não é verificado. Isto compila sem um aviso:
function eNumero(x: unknown): x is number {
return typeof x === 'string'; // mentira, e ninguém repara
}É o mesmo género de poder que um as dá, mas concentrado num sítio só. É isso que o torna aceitável: a afirmação fica numa função pequena, com nome, que se pode testar — em vez de espalhada por vinte sítios.
4. Antes de escrever um, ver se é preciso
Muitas verificações já estreitam sozinhas, e o predicado seria trabalho a mais:
typeof x === 'string' // estreita
Array.isArray(x) // estreita
erro instanceof Prisma.Error // estreita
'codigo' in resposta // estreitaO último é o mais esquecido, e resolve boa parte dos casos de uniões de objetos sem necessidade de nada mais.
5. O caso em que isto brilha mesmo
Uma união discriminada com um campo que diz qual é qual:
type Resultado =
| { estado: 'ok'; dados: string[] }
| { estado: 'erro'; mensagem: string };
function mostrar(r: Resultado) {
if (r.estado === 'erro') {
return r.mensagem; // só este ramo tem mensagem
}
return r.dados.length; // e só este tem dados
}Aqui não é preciso predicado nenhum: o compilador segue o campo estado e sabe, em cada ramo, com qual das formas está a lidar. É o padrão que torna metade dos predicados desnecessários — e está mais desenvolvido no artigo sobre uniões discriminadas.
O resumo
Um predicado serve para levar uma verificação para fora sem o compilador perder o fio. Antes de escrever um, vale a pena ver se o typeof, o in ou um campo discriminante já não fazem o trabalho — porque esses o compilador verifica, e um predicado é uma promessa que ninguém confere.