Tipos Condicionales
En el corazón de la mayoría de los programas útiles, tenemos que tomar decisiones basadas en las entradas. Los programas JavaScript no son diferentes, pero dado que los valores pueden ser fácilmente introspeccionados, esas decisiones también se basan en los tipos de entradas. Los tipos condicionales ayudan a describir la relación entre los tipos de entradas y salidas.
interface Animal {
live(): void;
}
interface Dog extends Animal {
woof(): void;
}
type Example1 = Dog extends Animal ? number : string;
type Example1 = number
type Example2 = RegExp extends Animal ? number : string;
type Example2 = string
Los tipos condicionales toman una forma que se parece un poco a las expresiones condicionales (condition ? trueExpression : falseExpression
) en JavaScript:
SomeType extends OtherType ? TrueType : FalseType;
Cuando el tipo de la izquierda de extends
se puede asignar al de la derecha, obtendrás el tipo en la primera rama (la rama “verdadera”); de lo contrario, obtendrás el tipo en la última rama (la rama “falsa”).
De los ejemplos anteriores, los tipos condicionales pueden no parecer útiles de inmediato; ¡podemos decirnos que Dog extends Animal
y elegir number
o string
!
Pero el poder de los tipos condicionales proviene de su uso con generics.
Por ejemplo, tomemos la siguiente función createLabel
:
interface IdLabel {
id: number /* algunos campos */;
}
interface NameLabel {
name: string /* otros campos */;
}
function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
throw "unimplemented";
}
Estas sobrecargas para createLabel
describen una única función de JavaScript que realiza una elección según los tipos de sus entradas. Ten en cuenta algunas cosas:
- Si una biblioteca tiene que hacer el mismo tipo de elección una y otra vez en toda su API, esto se vuelve engorroso.
- Tenemos que crear tres sobrecargas: una para cada caso en el que estemos seguros del tipo (una para
string
y otra paranumber
), y otra para el caso más general (tomando unstring | number
). Por cada nuevo tipo quecreateLabel
puede manejar, el número de sobrecargas crece exponencialmente.
En lugar de eso, podemos codificar esa lógica en un tipo condicional:
type NameOrId<T extends number | string> = T extends number
? IdLabel
: NameLabel;
Luego podemos usar ese tipo condicional para simplificar nuestras sobrecargas a una sola función sin sobrecargas.
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
throw "unimplemented";
}
let a = createLabel("typescript");
let a: NameLabel
let b = createLabel(2.8);
let b: IdLabel
let c = createLabel(Math.random() ? "hello" : 42);
let c: NameLabel | IdLabel
Restricciones de tipo condicional
A menudo, las comprobaciones de tipo condicional nos proporcionarán información nueva. Así como el estrechamiento con protecciones de tipo puede darnos un tipo más específico, la rama verdadera de un tipo condicional restringirá aún más los generics según el tipo que comparamos.
Por ejemplo, tomemos lo siguiente:
type MessageOf<T> = T["message"];
Type '"message"' cannot be used to index type 'T'.
En este ejemplo, se producen errores de TypeScript porque no se sabe si T
tiene una propiedad llamada message
.
Podríamos restringir T
y TypeScript ya no se quejaría:
type MessageOf<T extends { message: unknown }> = T["message"];
interface Email {
message: string;
}
type EmailMessageContents = MessageOf<Email>;
type EmailMessageContents = string
Sin embargo, ¿qué pasaría si quisiéramos que MessageOf
tomara cualquier tipo y que el valor predeterminado fuera algo como never
si una propiedad message
no está disponible?
Podemos hacer esto eliminando la restricción e introduciendo un tipo condicional:
type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;
interface Email {
message: string;
}
interface Dog {
bark(): void;
}
type EmailMessageContents = MessageOf<Email>;
type EmailMessageContents = string
type DogMessageContents = MessageOf<Dog>;
type DogMessageContents = never
Dentro de la rama verdadera, TypeScript sabe que T
tendrá una propiedad message
.
Como otro ejemplo, también podríamos escribir un tipo llamado Flatten
que aplana los tipos de arrays a sus tipos de elementos, pero de lo contrario los deja tal cual:
type Flatten<T> = T extends any[] ? T[number] : T;
// Extrae el tipo de elemento.
type Str = Flatten<string[]>;
type Str = string
// Deja el tipo tal cual.
type Num = Flatten<number>;
type Num = number
Cuando a Flatten
se le asigna un tipo de array, utiliza un acceso indexado con number
para obtener el tipo de elemento de string[]
.
De lo contrario, simplemente devuelve el tipo que se le proporcionó.
Inferir dentro de tipos condicionales
Nos encontramos usando tipos condicionales para aplicar restricciones y luego extraer tipos. Esta termina siendo una operación tan común que los tipos condicionales la hacen más fácil.
Los tipos condicionales nos brindan una manera de inferir a partir de los tipos que comparamos en la rama verdadera usando la palabra clave infer
.
Por ejemplo, podríamos haber inferido el tipo de elemento en Flatten
en lugar de recuperarlo “manualmente” con un tipo de acceso indexado:
type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;
Aquí usamos la palabra clave infer
para introducir declarativamente una nueva variable de tipo genérico llamada Item
en lugar de especificar cómo recuperar el tipo de elemento de Type
dentro de la rama verdadera.
Esto nos libera de tener que pensar en cómo profundizar y sondear la estructura de los tipos que nos interesan.
Podemos escribir algunos alias de tipos de ayuda útiles usando la palabra clave infer
.
Por ejemplo, para casos simples, podemos extraer el tipo de retorno de los tipos de funciones:
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
? Return
: never;
type Num = GetReturnType<() => number>;
type Num = number
type Str = GetReturnType<(x: string) => string>;
type Str = string
type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>;
type Bools = boolean[]
Cuando se infiere a partir de un tipo con múltiples firmas de llamada (como el tipo de una función sobrecargada), las inferencias se hacen a partir de la última firma (que, presumiblemente, es el caso más permisivo). No es posible realizar una resolución de sobrecarga basada en una lista de tipos de argumentos.
declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;
type T1 = ReturnType<typeof stringOrNum>;
type T1 = string | number
Tipos condicionales distributivos
Cuando los tipos condicionales actúan sobre un tipo genérico, se vuelven distributivos cuando se les da un tipo de unión. Por ejemplo, toma lo siguiente:
type ToArray<Type> = Type extends any ? Type[] : never;
Si conectamos un tipo de unión en ToArray
, entonces el tipo condicional se aplicará a cada miembro de esa unión.
type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>;
type StrArrOrNumArr = string[] | number[]
Lo que sucede aquí es que ToArray
se distribuye en:
string | number;
y mapea cada tipo de miembro de la unión, a lo que es efectivamente:
ToArray<string> | ToArray<number>;
lo cual nos deja con:
string[] | number[];
Normalmente, la distributividad es el comportamiento deseado.
Para evitar ese comportamiento, puedes rodear cada lado de la palabra clave extends
entre corchetes.
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;
// 'ArrOfStrOrNum' no es más una unión.
type ArrOfStrOrNum = ToArrayNonDist<string | number>;
type ArrOfStrOrNum = (string | number)[]