Introdução
Dentre as diversas ferramentas de tipagem avançada que o TypeScript provê, tipos condicionais são uma das mais importantes, pois nos dá habilidade de criar tipos não uniformes, ou seja, que podem variar conforme a entrada.
Sua sintaxe é análoga a do operador ternário do TypeScript, sendo que, a condição deve expressar um teste de relação entre os tipos.
Exemplo lado a lado:
* A condição pode ser qualquer tipo de operação que
* resulte em um boleano.
*/
const ternary = condition ? expr1 : expr2;
/*
* A condição deve ser criada testando a relação entre
* os dois tipos, sempre utilizando o operador `extends`.
*/
type Conditional<T, U> = T extends U ? string : number;
Condições simples
type R1 = IsString<‘Hello‘>;
// => true
type R2 = IsString<2>;
// => false
No exemplo acima, criamos o tipo IsString que verifica se T é atribuível a string, retornando true caso verdadeiro, ou false caso contrário.
Agora, observe atentamente o exemplo abaixo:
const year = new Date().getFullYear() === 2020;
type R = Is2020<typeof year>;
// => ‘yes’ | ‘no’
Indo direto para a última linha do exemplo, note que Is2020<typeof year> está retornando como resultado uma união entre os dois tipos possíveis especificados na sua assinatura, mas por quê? Para responder essa pergunta, temos que ter em mente que o TypeScript roda em tempo de desenvolvimento e não consegue inferir tipos de runtime, que no nosso caso, é o trecho new Date().getFullYear() === 2020. Como inferir não foi possível, recebemos como resultado uma união com todas as possibilidades.
Condições encadeadas
Assim como um ternário comum, podemos encadear tipos condicionais e criar tipagens mais robustas baseadas em múltiplas regras.
? T extends ‘Angular‘
? true
: T extends ‘Vue‘
? true
: T extends ‘React‘
? true
: ‘Insert a valid framework.‘
: never;
type R1 = IsFrontEndFramework<‘React‘>;
// => true
type R2 = IsFrontEndFramework<‘Hello‘>;
// => ‘Insert a valid framework.’
type R3 = IsFrontEndFramework<920>;
// => never
Inferindo Tipos
Até agora vimos que é possível encadear e criar condições variadas que cobrem uma infinidade de cenários, porém, em certos casos, ainda é muito trabalhoso ou praticamente impossível criar a constraint correta apenas utilizando condições simples. Para esses cenários mais complexos, temos a keyword infer, que auxilia na inferência de tipos dinâmicos dentro das nossas condições. A única limitação aqui é: você só poderá utilizar infer numa condição extends.
Array
Vamos observar o exemplo a seguir, onde utilizamos infer para inferir o tipo de um array:
type R1 = InferArrayType<Array<string>>;
// => string
type R2 = InferArrayType<Array<number>>;
// => number
type R3 = InferArrayType<Array<Array<string>>>;
// => string[]
Atenção no InferArrayType, um tipo que recebe um genérico A, valida se este tipo é um array e, caso seja, retorna o tipo do elemento do array. Note que o tipo U é inferido dinamicamente utilizando a keyword infer.
Esse é um exemplo simples, mas que demonstra a potência do infer em situações mais complexas.
Agora, vamos ver um exemplo mais complexo, onde utilizamos infer para converter um objeto de duas propriedades em uma tupla:
type Result = ObjectToTuple<{a: string; b: number}>;
// => [string, number]
No exemplo acima, ObjectToTuple é um tipo que recebe um genérico T, valida se T é um objeto com as propriedades a e b, e retorna um array com os tipos das propriedades a e b. Note que A e B são inferidos dinamicamente utilizando a keyword infer.
Inferindo parâmetros ou retorno de funções
A versatilidade do infer vai além da inferência de tipos simples, também sendo possível inferir tipos de funções. No exemplo abaixo, iremos inferir o tipo do parâmetro de uma função de aridade 1 e o tipo de retorno da função.
type R1 = FunctionArg<(x: number) => boolean>;
// => number
type R2 = FunctionArg<(x: number, y: number) => boolean>;
// => never
Neste exemplo, a função FunctionArg recebe um tipo genérico F e verifica se F é uma função de aridade 1. Caso seja, o tipo do parâmetro é inferido e retornado. Caso a aridade seja maior que 1, o tipo never é retornado.
Mas e se quisermos inferir os tipos de uma função com aridade maior que 1? Podemos fazer isso com a seguinte implementação:
type R1 = FunctionArg<(x: number) => boolean>;
// => [number]
type R2 = FunctionArg<(x: number, y: number) => boolean>;
// => [number, number]
Neste caso, a função FunctionArg recebe um tipo genérico F e verifica se F é uma função de aridade n. Caso seja, o tipo dos parâmetros é inferido e retornado em um array.
Para inferir o tipo de retorno de uma função, podemos fazer o seguinte:
type R1 = FunctionReturn<(x: number) => boolean>;
// => boolean
type R2 = FunctionReturn<(x: number, y: number) => boolean>;
// => boolean
Neste caso, a função FunctionReturn recebe um tipo genérico F e verifica se F é uma função de aridade n. Caso seja, o tipo de retorno é inferido e retornado.
Importante
Apesar de ser uma ferramenta poderosa, tipos condicionais podem ser complexos e difíceis de entender, especialmente quando combinados com infer.
É de extrema importância avaliar as necessidades de cada caso para decidir se é ou não necessário.
Tipos condicionais mal escritos podem piorar a experiência de desenvolvimento e tornar o código mais difícil de entender. Use com moderação e sempre busque a simplicidade.
Conclusão
Tipos condicionais são uma ferramenta poderosa para criar tipos não uniformes e mais robustos. A keyword infer é uma ferramenta essencial para inferir tipos dinâmicos dentro de condições. Combinando essas duas ferramentas, podemos criar tipos complexos e expressivos que cobrem uma grande variedade de cenários e que aumentam a segurança e a robustez do nosso código.