Iago Belo

Entendendo e Utilizando Tipos Condicionais (TypeScript)

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 IsString<T> = T extends string ? true : false;

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:

type Is2020<T> = T extends true ? 'yes' : 'no';

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.

type IsFrontEndFramework<T> = T extends string
? 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 InferArrayType<A> = A extends Array<infer U> ? U : never;

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 ObjectToTuple<T> = T extends { a: infer A; b: infer B } ? [A, B] : never;

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 FunctionArg<F> = F extends (p: infer P) => unknown ? P : never;

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 FunctionArg<F> = F extends (...args: infer P) => unknown ? P : never;

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 FunctionReturn<F> = F extends (...args: any[]) => infer R ? R : never;

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

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.