Estrechamiento o Narrowing en TypeScript
Imagina que tenemos una función llamada padLeft
.
function padLeft(padding: number | string, input: string): string {
throw new Error("Not implemented yet!");
}
Si padding
es un number
, lo tratará como la cantidad de espacios que queremos anteponer a input
.
Si padding
es un string
, simplemente debe anteponer padding
a input
.
Intentemos implementar la lógica para cuando a padLeft
se le pasa un number
para padding
.
function padLeft(padding: number | string, input: string): string {
return " ".repeat(padding) + input;
}
Argument of type 'string | number' is not assignable to parameter of type 'number'.
Type 'string' is not assignable to type 'number'.
Oh, oh, recibimos un error en padding
.
TypeScript nos advierte que estamos pasando un valor con tipo number | string
a la función repeat
, que solo acepta un number
, y es correcto.
En otras palabras, no hemos verificado explícitamente si padding
es un number
primero, ni estamos manejando el caso en el que es un string
, así que hagamos exactamente eso.
function padLeft(padding: number | string, input: string): string {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
Si esto parece principalmente un código JavaScript poco interesante, ese es el punto. Aparte de las anotaciones que implementamos, este código TypeScript se parece a JavaScript. La idea es que el sistema de tipos de TypeScript tiene como objetivo hacer que sea lo más fácil posible escribir código JavaScript típico sin hacer todo lo posible para obtener seguridad de tipos.
Si bien puede que no parezca mucho, en realidad están sucediendo muchas cosas bajo las sábanas aquí.
Al igual que TypeScript analiza los valores en tiempo de ejecución utilizando tipos estáticos, superpone el análisis de tipos en las construcciones de flujo de control de tiempo de ejecución de JavaScript como if/else
, ternarios condicionales, bucles, comprobaciones de veracidad, etc., que pueden afectar esos tipos.
Dentro de nuestra verificación if
, TypeScript ve typeof padding === "number"
y lo entiende como una forma especial de código llamada type guard.
TypeScript sigue posibles rutas de ejecución que nuestros programas pueden tomar para analizar el tipo de valor más específico posible en una posición determinada.
Examina estas comprobaciones especiales (llamadas protecciones de tipo o type guards) y asignaciones, y el proceso de refinar tipos a tipos más específicos de los declarados se llama estrechamiento (o narrowing).
En muchos editores podemos observar estos tipos a medida que cambian, e incluso lo haremos en nuestros ejemplos.
function padLeft(padding: number | string, input: string): string {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
(parameter) padding: number
}
return padding + input;
(parameter) padding: string
}
Hay un par de construcciones diferentes que TypeScript entiende para estrechar.
El operador type guard typeof
Como hemos visto, JavaScript admite un operador typeof
que puede brindar información muy básica sobre el tipo de valores que tenemos en tiempo de ejecución.
TypeScript espera que esto devuelva un determinado conjunto de cadenas:
"string"
"number"
"bigint"
"boolean"
"symbol"
"undefined"
"object"
"function"
Como vimos con padLeft
, este operador aparece con bastante frecuencia en varias bibliotecas de JavaScript, y TypeScript puede entenderlo para estrechar tipos en diferentes ramas.
En TypeScript, comparar el valor devuelto por typeof
es una protección de tipo (type guard).
Debido a que TypeScript codifica cómo opera typeof
en diferentes valores, conoce algunas de sus peculiaridades en JavaScript.
Por ejemplo, observa que en la lista anterior, typeof
no devuelve la cadena null
.
Mira el siguiente ejemplo:
function printAll(strs: string | string[] | null) {
if (typeof strs === "object") {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
} else {
// do nothing
}
}
'strs' is possibly 'null'.
En la función printAll
, intentamos verificar si strs
es un objeto para ver si es un tipo de array (ahora podría ser un buen momento para reforzar que los arrays son tipos de objetos en JavaScript).
¡Pero resulta que en JavaScript, typeof null
es en realidad "object"
!
Este es uno de esos desafortunados accidentes de la historia.
Puede que los usuarios con suficiente experiencia no se sorprendan, pero no todos se han topado con esto en JavaScript; afortunadamente, TypeScript nos permite saber que strs
solo se estrechó a string[] | null
en lugar de simplemente string[]
.
Esto podría ser una buena transición hacia lo que llamaremos verificación de “veracidad” (truthiness).
Estrechamiento de veracidad
Puede que Truthiness (veracidad) no sea una palabra que encuentres en el diccionario, pero es algo de lo que escucharás en JavaScript.
En JavaScript, podemos usar cualquier expresión en condicionales, &&
, ||
, declaraciones if
, negaciones booleanas (!
) y más.
Por ejemplo, las declaraciones if
no esperan que su condición siempre tenga el tipo boolean
.
function getUsersOnlineMessage(numUsersOnline: number) {
if (numUsersOnline) {
return `There are ${numUsersOnline} online now!`;
}
return "Nobody's here. :(";
}
En JavaScript, las construcciones como if
primero “coaccionan” sus condiciones a boolean
para darles sentido, y luego eligen sus ramas dependiendo de si el resultado es true
o false
.
Valores como
0
-NaN
""
(la cadena vacía)0n
(la versiónbigint
de cero)null
undefined
todos se coaccionan a false
y otros valores se coaccionan a true
.
Siempre puedes forzar valores a boolean
s ejecutándolos a través de la función Boolean
o usando la negación doble booleana más corta. (Este último tiene la ventaja de que TypeScript infiere un tipo booleano literal estrecho true
, mientras que infiere el primero como de tipo boolean
.
// ambos resultados serán 'true'
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true, value: true
Es bastante popular aprovechar este comportamiento, especialmente para protegerte contra valores como null
o undefined
.
Como ejemplo, intentemos usarlo para nuestra función printAll
.
function printAll(strs: string | string[] | null) {
if (strs && typeof strs === "object") {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
}
}
Notarás que nos hemos deshecho del error anterior al verificar si strs
es verdadero.
Esto al menos nos evita errores temidos cuando ejecutamos nuestro código como:
TypeError: null is not iterable
Ten en cuenta que la verificación de la veracidad de las primitivas a menudo puede ser propensa a errores.
Como ejemplo, considera un intento diferente de escribir printAll
function printAll(strs: string | string[] | null) {
// !!!!!!!!!!!!!!!!
// NO HAGAS ESTO!
// SIGUE LEYENDO
// !!!!!!!!!!!!!!!!
if (strs) {
if (typeof strs === "object") {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
}
}
}
Envolvimos todo el cuerpo de la función en una verificación veraz, pero esto tiene una desventaja sutil: es posible que ya no estemos manejando correctamente la cadena vacía.
TypeScript no nos hace ningún daño aquí, pero vale la pena señalar este comportamiento si estás menos familiarizado con JavaScript. TypeScript a menudo puede ayudarle a detectar errores desde el principio, pero si eliges no hacer nada con un valor, hay mucho que puedes hacer sin ser demasiado prescriptivo. Si lo deseas, puedes asegurarte de manejar situaciones como estas con un linter.
Una última palabra sobre el estrechamiento por veracidad es que las negaciones booleanas con !
se filtran de las ramas negadas.
function multiplyAll(
values: number[] | undefined,
factor: number
): number[] | undefined {
if (!values) {
return values;
} else {
return values.map((x) => x * factor);
}
}
Estrechamiento por igualdad
TypeScript también usa declaraciones switch
y operadores de igualdad como ===
, !==
, ==
y !=
para estrechar los tipos.
Por ejemplo:
function example(x: string | number, y: string | boolean) {
if (x === y) {
// Ahora podemos llamar a cualquier método de 'string' en 'x' o 'y'.
x.toUpperCase();
// (method) String.toUpperCase(): string
y.toLowerCase();
// (method) String.toLowerCase(): string
} else {
console.log(x);
// (parameter) x: string | number
console.log(y);
// (parameter) y: string | boolean
}
}
Cuando verificamos que x
e y
son iguales en el ejemplo anterior, TypeScript supo que sus tipos también tenían que ser iguales.
Dado que string
es el único tipo común que tanto x
como y
pueden adoptar, TypeScript sabe que x
e y
deben ser un string
en la primera rama.
Comparar valores literales específicos (a diferencia de variables) también funciona.
En nuestra sección sobre el estrechamiento por veracidad, escribimos una función printAll
que era propensa a errores porque accidentalmente no manejaba correctamente las cadenas vacías.
En su lugar, podríamos haber realizado una verificación específica para bloquear los null
s, y TypeScript aún elimina correctamente los null
s del tipo de strs
.
function printAll(strs: string | string[] | null) {
if (strs !== null) {
if (typeof strs === "object") {
for (const s of strs) {
// (parameter) strs: string[]
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
// (parameter) strs: string
}
}
}
Las comprobaciones de igualdad más flexibles de JavaScript con ==
y !=
también se estrechan correctamente.
Si no estás familiarizado, verificar si algo == null
en realidad no solo verifica si es específicamente el valor null
, sino que también verifica si es potencialmente undefined
.
Lo mismo se aplica a == undefined
: verifica si un valor es null
o undefined
.
interface Container {
value: number | null | undefined;
}
function multiplyValue(container: Container, factor: number) {
// Elimina tanto 'null' como 'undefined' del tipo.
if (container.value != null) {
console.log(container.value);
// (property) Container.value: number
// Ahora podemos multiplicar 'container.value' de forma segura.
container.value *= factor;
}
}
El operador de estrechamiento in
JavaScript tiene un operador para determinar si un objeto o su cadena prototipo tiene una propiedad con un nombre: el operador in
.
TypeScript tiene esto en cuenta como una forma de estrechar los tipos potenciales.
Por ejemplo, con el código: "value" in x
, donde "value"
es una cadena literal y x
es un tipo de unión.
La rama true
estrecha los tipos de x
que tienen una propiedad value
opcional o requerida, y la rama false
se estrecha a los tipos que tienen una propiedad valor
opcional o faltante.
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
return animal.swim();
}
return animal.fly();
}
Para reiterar, existirán propiedades opcionales en ambos lados para estrecharlas. Por ejemplo, un humano podría nadar y volar (con el equipo adecuado) y, por lo tanto, debería aparecer en ambos lados del chequeo “in”:
type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };
function move(animal: Fish | Bird | Human) {
if ("swim" in animal) {
animal;
// (parameter) animal: Fish | Human
} else {
animal;
// (parameter) animal: Bird | Human
}
}
El operador de estrechamiento instanceof
JavaScript tiene un operador para verificar si un valor es o no una “instancia” de otro valor.
Más específicamente, en JavaScript, x instanceof Foo
comprueba si la cadena de prototipos de x
contiene Foo.prototype
.
Si bien no profundizaremos aquí y verás más de esto cuando entremos en las clases, aún pueden ser útiles para la mayoría de los valores que se pueden construir con new
.
Como habrás adivinado, instanceof
también es una protección de tipos, y TypeScript se estrecha en las ramas protegidas por instanceof
s.
function logValue(x: Date | string) {
if (x instanceof Date) {
console.log(x.toUTCString());
// (parameter) x: Date
} else {
console.log(x.toUpperCase());
// (parameter) x: string
}
}
Asignaciones
Como mencionamos anteriormente, cuando asignamos cualquier variable, TypeScript mira el lado derecho de la asignación y estrecha el lado izquierdo de manera apropiada.
let x = Math.random() < 0.5 ? 10 : "hello world!";
let x: string | number
x = 1;
console.log(x);
let x: number
x = "goodbye!";
console.log(x);
let x: string
Fíjate que cada una de estas asignaciones es válida.
Aunque el tipo observado de x
cambió a number
después de nuestra primera asignación, todavía pudimos asignar un string
a x
.
Esto se debe a que el tipo declarado de x
(el tipo con el que comenzó x
) es string | number
, y la asignabilidad siempre se compara con el tipo declarado.
Si hubiéramos asignado un boolean
a x
, habríamos visto un error ya que no era parte del tipo declarado.
let x = Math.random() < 0.5 ? 10 : "hello world!";
let x: string | number
x = 1;
console.log(x);
let x: number
x = true;
console.log(x);
let x: string | number
Type 'boolean' is not assignable to type 'string | number'.
Análisis de control de flujo
Hasta este punto, hemos repasado algunos ejemplos básicos de cómo TypeScript se estrecha a ramas específicas.
Pero está sucediendo algo más que simplemente salir de cada variable y buscar protecciones de tipo en if
s, while
s, condicionales, etc.
Por ejemplo
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
padLeft
retorna desde su primer bloque if
.
TypeScript pudo analizar este código y ver que el resto del cuerpo (return padding + input;
) es inalcanzable en el caso de que padding
sea un number
.
Como resultado, pudo eliminar number
del tipo de padding
(estrechándose de string | number
a string
) para el resto de la función.
Este análisis de código basado en la accesibilidad se llama análisis de control de flujo, y TypeScript usa este análisis de flujo para estrechar los tipos a medida que encuentra asignaciones y protecciones de tipos. Cuando se analiza una variable, el control de flujo puede dividirse y volver a fusionarse una y otra vez, y se puede observar que esa variable tiene un tipo diferente en cada punto.
function example() {
let x: string | number | boolean;
x = Math.random() < 0.5;
console.log(x);
// let x: boolean
if (Math.random() < 0.5) {
x = "hello";
console.log(x);
// let x: string
} else {
x = 100;
console.log(x);
// let x: number
}
return x;
// let x: string | number
}
Usando predicados de tipo
Hemos trabajado con construcciones de JavaScript existentes para manejar el estrechamiento hasta ahora, sin embargo, a veces desearás un control más directo sobre cómo cambian los tipos a lo largo de tu código.
Para definir una protección de tipo definida por el usuario, simplemente necesitamos definir una función cuyo tipo de retorno sea un predicado de tipo:
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
pet is Fish
es nuestro predicado de tipo en este ejemplo.
Un predicado toma la forma parameterName is Type
, donde parameterName
debe ser el nombre de un parámetro de la firma de la función actual.
Cada vez que se llama a isFish
con alguna variable, TypeScript estrechará esa variable a ese tipo específico si el tipo original es compatible.
// Ambas llamadas a 'swim' y 'fly' están ahora okay.
let pet = getSmallPet();
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}
Observa que TypeScript no solo sabe que pet
es un Fish
en la rama if
;
también sabe que en la rama else
, no tienes un Fish
, por lo que debes tener un Bird
.
Puedes usar el tipo de protección isFish
para filtrar un array de Fish | Bird
y obtener un array de Fish
:
const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// o equivalentemente
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
// Es posible que sea necesario repetir el predicado para ejemplos más complejos.
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
if (pet.name === "sharkey") return false;
return isFish(pet);
});
Además, las clases pueden usar this is Type
para limitar su tipo.
Funciones de aserción
Los tipos también se pueden limitar usando Funciones de aserción ↗.
Uniones discriminadas
La mayoría de los ejemplos que hemos visto hasta ahora se han centrado en estrechar variables individuales con tipos simples como string
, boolean
y number
.
Si bien esto es común, la mayor parte del tiempo en JavaScript trataremos con estructuras un poco más complejas.
Para motivarte, imaginemos que estamos tratando de codificar formas como círculos y cuadrados.
Los círculos realizan un seguimiento de sus radios y los cuadrados realizan un seguimiento de la longitud de sus lados.
Usaremos un campo llamado kind
para indicar con qué forma estamos tratando.
Aquí hay un primer intento de definir Shape
.
interface Shape {
kind: "circle" | "square";
radius?: number;
sideLength?: number;
}
Observa que estamos usando una unión de tipos literales de string: "circle"
y "square"
para decirnos si debemos tratar la forma como un círculo o un cuadrado respectivamente.
Usando "circle" | "square"
en lugar de string
, podemos evitar problemas de ortografía.
function handleShape(shape: Shape) {
// oops!
if (shape.kind === "rect") {
// ...
}
}
This comparison appears to be unintentional because the types '"circle" | "square"' and '"rect"' have no overlap.
Podemos escribir una función getArea
que aplique la lógica correcta según si se trata de un círculo o un cuadrado.
Primero intentaremos trabajar con círculos.
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
}
'shape.radius' is possibly 'undefined'.
En strictNullChecks
↗ eso nos da un error, lo cual es apropiado ya que es posible que radius
no esté definido.
Pero ¿qué pasa si realizamos las comprobaciones apropiadas en la propiedad kind
?
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
}
}
'shape.radius' is possibly 'undefined'.
Hmm, TypeScript todavía no sabe qué hacer aquí.
Hemos llegado a un punto en el que sabemos más sobre nuestros valores que el verificador de tipos.
Podríamos intentar utilizar una aserción de no null (un !
después de shape.radius
) para decir que radius
definitivamente está presente.
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius! ** 2;
}
}
Pero esto no parece ideal.
Tuvimos que gritarle un poco al verificador de tipos con esas aserciones no nulas (!
) para convencerlo de que shape.radius
estaba definido, pero esas aserciones son propensas a errores si comenzamos a mover el código.
Además, fuera de strictNullChecks
↗ podemos acceder accidentalmente a cualquiera de esos campos de todos modos (ya que se supone que las propiedades opcionales siempre están presentes al leerlos).
Definitivamente podemos hacerlo mejor.
El problema con esta codificación de Shape
es que el verificador de tipos no tiene ninguna forma de saber si radius
o sideLength
están presentes según la propiedad kind
.
Necesitamos comunicar lo que nosotros sabemos al verificador de tipos.
Con eso en mente, demos otro paso para definir Shape
.
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
Aquí, hemos separado correctamente Shape
en dos tipos con diferentes valores para la propiedad kind
, pero radius
y sideLength
se declaran como propiedades requeridas en sus respectivos tipos.
Veamos qué sucede aquí cuando intentamos acceder al radius
de una Shape
.
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
}
Property 'radius' does not exist on type 'Shape'.
Property 'radius' does not exist on type 'Square'.
Al igual que con nuestra primera definición de Shape
, esto sigue siendo un error.
Cuando radius
era opcional, obtuvimos un error (con strictNullChecks
↗ habilitado) porque TypeScript no podía decir si la propiedad estaba presente.
Ahora que Shape
es una unión, TypeScript nos dice que shape
podría ser un Square
, ¡y los Square
no tienen un radius
definido!
Ambas interpretaciones son correctas, pero solo la codificación de unión de Shape
causará un error independientemente de cómo esté configurado strictNullChecks
↗.
¿Pero qué pasaría si intentáramos verificar la propiedad kind
nuevamente?
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
// (parameter) shape: Circle
}
}
¡Eso eliminó el error! Cuando cada tipo en una unión contiene una propiedad común con tipos literales, TypeScript considera que se trata de una unión discriminada y puede estrechar los miembros de la unión.
En este caso, kind
era esa propiedad común (que es lo que se considera una propiedad discriminante de Shape
).
Al comprobar si la propiedad kind
era "circle"
, se eliminaron todos los tipos en Shape
que no tenían una propiedad kind
con el tipo "circle"
.
Eso redujo shape
al tipo Circle
.
La misma verificación también funciona con declaraciones switch
.
Ahora podemos intentar escribir nuestro getArea
completo sin ninguna molesta afirmación no nula !
.
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
// (parameter) shape: Circle
case "square":
return shape.sideLength ** 2;
// (parameter) shape: Square
}
}
Lo importante aquí era la codificación de Shape
.
Comunicar la información correcta a TypeScript (que Circle
y Square
eran en realidad dos tipos separados con campos kind
específicos) era crucial.
Hacer eso nos permite escribir código TypeScript con seguridad de tipos que no se ve diferente al JavaScript que habríamos escrito de otra manera.
A partir de ahí, el sistema de tipos pudo hacer lo “correcto” y descubrir los tipos en cada rama de nuestra declaración switch
.
Aparte, intenta jugar con el ejemplo anterior y elimina algunas de las palabras clave de retorno. Verás que la verificación de tipos puede ayudar a evitar errores al pasar accidentalmente por diferentes cláusulas en una declaración
switch
.
Las uniones discriminadas sirven para algo más que hablar de círculos y cuadrados. Son buenas para representar cualquier tipo de esquema de mensajería en JavaScript, como cuando se envían mensajes a través de la red (comunicación cliente/servidor) o codifican mutaciones en un framework de gestión de estado.
El tipo never
Al estrechar, puedes reducir las opciones de una unión hasta un punto en el que hayas eliminado todas las posibilidades y no te quede nada.
En esos casos, TypeScript usará un tipo never
para representar un estado que no debería existir.
Comprobación de exhaustividad
El tipo never
se puede asignar a todos los tipos; sin embargo, ningún tipo se puede asignar a never
(excepto el propio never
). Esto significa que puedes utilizar el estrechamiento y confiar en que never
aparezca para realizar una verificación exhaustiva en una declaración switch
.
Por ejemplo, agregar un valor default
a nuestra función getArea
que intenta asignar never
a shape
no generará un error cuando se hayan manejado todos los casos posibles.
type Shape = Circle | Square;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
Agregar un nuevo miembro a la unión Shape
provocará un error de TypeScript:
interface Triangle {
kind: "triangle";
sideLength: number;
}
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
Type 'Triangle' is not assignable to type 'never'.