Uniones discriminadas y tipo never en TypeScript
Aprende a usar uniones discriminadas para manejar tipos complejos en TypeScript y cómo el tipo never ayuda a asegurar que tus comprobaciones sean exhaustivas, evitando errores comunes y mejorando la seguridad de tu código
Narrowing (Refinamiento de tipos) en TypeScript
TypeScript ofrece mecanismos para ir refinando (narrowing) el tipo de una variable a medida que el código avanza, permitiendo trabajar con tipos más específicos basados en comprobaciones de control de flujo.
padLeft
Ejemplo basico con function padLeft(padding: number | string, input: string): string {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
- Si
padding
es un número, se repite ese número de espacios. - Si
padding
es una cadena, se concatena directamente. - El uso de
typeof
es una guardia de tipo (type guard
) que permite a TypeScript entender qué tipo tienepadding
dentro de cada rama delif
.
¿Que es Narrowing?
- Narrowing es el proceso de tomar un tipo amplio y reducirlo a uno más específico dentro de un bloque de código, usando controles de flujo (if, else, switch, etc.).
- Las guardias de tipo permiten decirle a TypeScript que, dentro de cierto contexto, una variable tiene un tipo más concreto.
Constructos comunes para Narrowing
typeof
1. JavaScript permite usar el operador typeof
para comprobar el tipo en tiempo de ejecución. TypeScript reconoce estas comprobaciones para refinar tipos.
Valores posibles de typeof
:
"string"
"number"
"bigint"
"boolean"
"symbol"
"undefined"
"object"
"function"
Ejemplo problemático con null
:
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);
}
}
En JavaScript,
typeof null
es"object"
, un comportamiento histórico raro que puede causar confusión.
2. Truthiness Narrowing (Comprobacion de verdad)
En JavaScript, valores como 0
, NaN
, ""
(cadena vacía), null
, undefined
, 0n
(bigint cero) se evalúan como false en condiciones. Otros valores se evalúan como true.
Esto se usa para evitar errores, por ejemplo:
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);
}
}
Cuidado: Usar solo una comprobación de verdad puede omitir casos importantes, como la cadena vacía ""
.
3. Negacion para filtrar tipos
function multiplyAll(values: number[] | undefined, factor: number): number[] | undefined {
if (!values) {
return values;
}
return values.map(x => x * factor);
}
Si values
es falso (undefined
o null
), retornamos, sino podemos usarlo como arreglo seguro.
4. Equality Narrowing (Comprobacion de igualdad)
Comparaciones con ===
, !==
, ==
y !=
también refinan tipos.
function example(x: string | number, y: string | boolean) {
if (x === y) {
// Aquí TypeScript sabe que x e y son string
x.toUpperCase();
y.toLowerCase();
}
}
También se puede usar para filtrar valores no deseados:
function printAll(strs: string | string[] | null) {
if (strs !== null) {
// Ahora strs es string | string[]
}
}
Usar
!= null
filtra tantonull
comoundefined
.
in
5. Narrowing con operador Se puede usar para detectar si un objeto tiene una propiedad:
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
return animal.swim();
}
return animal.fly();
}
Con tipos opcionales, el refinamiento incluye tipos que tienen o no la propiedad:
type Human = { swim?: () => void; fly?: () => void };
function move(animal: Fish | Bird | Human) {
if ("swim" in animal) {
// animal es Fish | Human (porque Human puede tener swim)
} else {
// animal es Bird | Human (porque Human puede no tener swim)
}
}
instanceof y Narrowing Reduccion de tipo
JavaScript tiene un operador para verificar si un valor es una "instancia" de otro. Más específicamente, x instanceof Foo
comprueba si la cadena de prototipos de x
contiene Foo.prototype
. No entraremos en mucho detalle aquí, y verás más sobre esto cuando trabajemos con clases, pero instanceof
es útil para la mayoría de los valores que se pueden crear con new
.
Como quizás sospeches, instanceof
también es una guarda de tipo (type guard) en TypeScript, y permite que el compilador reduzca (narrow) el tipo dentro de las ramas protegidas con instanceof
.
function logValue(x: Date | string) {
if (x instanceof Date) {
console.log(x.toUTCString());
// Aquí TypeScript sabe que x es Date
} else {
console.log(x.toUpperCase());
// Aquí TypeScript sabe que x es string
}
}
Asignaciones
Como mencionamos antes, cuando asignamos un valor a una variable, TypeScript analiza el lado derecho y reduce el tipo del lado izquierdo de forma adecuada.
let x = Math.random() < 0.5 ? 10 : "hello world!";
// x tiene tipo string | number
x = 1; // válido, 1 es number, parte del tipo declarado
console.log(x);
x = "goodbye!"; // válido, string también está permitido
console.log(x);
Observa que, aunque después de la primera asignación x
parece ser un número, aún podemos asignarle una cadena porque el tipo declarado inicial de x
es string | number
, y las asignaciones se verifican contra ese tipo declarado.
Si intentáramos asignar un valor de tipo booleano, TypeScript nos daría un error porque no está incluido en el tipo declarado:
let x = Math.random() < 0.5 ? 10 : "hello world!";
x = 1; // válido
x = true; // Error: Type 'boolean' no es asignable a tipo 'string | number'.
Analisis de flujo de control (Control Flow Analysis)
Hasta ahora hemos visto ejemplos básicos de cómo TypeScript reduce tipos dentro de ramas condicionales. Pero hay más en juego que simplemente buscar guardas de tipo en if
, while
, condicionales, etc.
Por ejemplo:
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
La función retorna dentro del primer bloque if
. TypeScript detecta que el resto del código (return padding + input;
) solo se ejecuta si padding
no es un número, por lo que reduce el tipo de padding
de string | number
a solo string
para esa parte.
Este tipo de análisis basado en la alcanzabilidad del código se llama análisis de flujo de control, y TypeScript lo utiliza para reducir tipos conforme encuentra guardas de tipo y asignaciones. El flujo puede dividirse y volver a unirse varias veces, y la variable puede tener tipos diferentes en cada punto.
Ejemplo
function example() {
let x: string | number | boolean;
x = Math.random() < 0.5;
console.log(x); // Aquí, x es boolean
if (Math.random() < 0.5) {
x = "hello";
console.log(x); // Aquí, x es string
} else {
x = 100;
console.log(x); // Aquí, x es number
}
return x; // Al final, x puede ser string o number
}
Usando type predicates (predicados de tipo personalizados)
Hemos trabajado con guardas de tipo existentes en JavaScript, pero a veces quieres tener un control más directo sobre cómo cambia el tipo en tu código.
Para definir una guarda de tipo personalizada, simplemente defines una función cuyo tipo de retorno es un predicado de tipo:
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
Aquí, pet is Fish
es el predicado de tipo, que indica que si la función devuelve true
, entonces pet
es de tipo Fish
.
Cuando llamamos a isFish
con una variable, TypeScript reduce el tipo de esa variable a Fish
dentro de la rama correspondiente.
let pet = getSmallPet();
if (isFish(pet)) {
pet.swim(); // Seguro, porque TypeScript sabe que pet es Fish aquí
} else {
pet.fly(); // Aquí sabe que pet no es Fish, así que debe ser Bird
}
Esto también funciona para filtrar arreglos:
const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// o también
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
Para casos más complejos, puedes definir predicados inline:
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
if (pet.name === "sharkey") return false;
return isFish(pet);
});
Uso en clases
Las clases también pueden usar este patrón de is Type
para reducir su tipo.
Assertion functions (Funciones de afirmacion)
Los tipos también pueden reducirse usando funciones de afirmación, que lanzan errores si un valor no cumple con ciertas condiciones, pero esto queda para otro momento.
Uniones discriminadas (Discriminated Unions)
La mayoría de los ejemplos que hemos visto hasta ahora se centran en cómo reducir (narrow) variables simples como string
, boolean
y number
. Aunque esto es común, la mayoría de las veces en JavaScript trabajaremos con estructuras un poco más complejas.
Para entender mejor, imaginemos que queremos modelar formas geométricas como círculos y cuadrados. Los círculos guardan su radio, y los cuadrados guardan la longitud de sus lados. Usaremos un campo llamado kind
para indicar de qué tipo de figura se trata. Aquí una primera definición de Shape
:
interface Shape {
kind: "circle" | "square";
radius?: number;
sideLength?: number;
}
Fíjate que usamos una unión de literales de cadena "circle"
y "square"
para indicar el tipo de figura, en lugar de usar simplemente string
. Esto ayuda a evitar errores de escritura.
function handleShape(shape: Shape) {
// ¡Ups!
if (shape.kind === "rect") {
// Esto genera error:
// La comparación parece accidental porque los tipos '"circle" | "square"' y '"rect"' no se solapan.
}
}
Podemos escribir una función getArea
que calcule el área, aplicando la lógica correcta según si la forma es un círculo o un cuadrado. Primero, intentemos con los círculos:
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
// Error: 'shape.radius' podría ser 'undefined'.
}
Bajo la opción strictNullChecks
esto genera un error, porque radius
puede no estar definido. ¿Y si hacemos la comprobación en la propiedad kind
?
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
// Error: 'shape.radius' podría ser 'undefined'.
}
}
Aún así TypeScript no sabe qué hacer. Aquí sabemos más sobre el valor que el compilador. Podríamos usar una aserción no nula (!
) para decir que radius
sí está definido:
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius! ** 2;
}
}
Pero esto no es ideal. Estamos "gritando" al compilador con !
, lo que puede ser peligroso si cambiamos el código luego. Además, sin strictNullChecks
podemos acceder accidentalmente a propiedades opcionales que no existen. Podemos hacerlo mejor.
Mejorando la definicion de Shape
El problema con la definición original es que el compilador no sabe si radius
o sideLength
existen según el valor de kind
. Necesitamos comunicar esta relación al compilador.
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
Ahora Circle
y Square
son dos tipos separados con propiedades obligatorias específicas.
Intentemos acceder al radius
en Shape
:
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
// Error: La propiedad 'radius' no existe en 'Shape' ni en 'Square'.
}
Esto da error porque shape
puede ser un Square
, que no tiene radius
.
¿Y si comprobamos kind
?
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
// Aquí shape es de tipo Circle, no hay error
}
}
¡Perfecto! Cuando todos los miembros de una unión tienen una propiedad común con tipos literales, TypeScript crea una unión discriminada y puede reducir el tipo según el valor de esa propiedad.
En este caso, kind
es la propiedad discriminante. Al comprobar si kind === "circle"
, TypeScript descarta todos los tipos que no tienen kind
con valor "circle"
, reduciendo el tipo a Circle
.
Lo mismo funciona con sentencias switch
:
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
}
}
La clave está en cómo definimos Shape
. Al comunicar correctamente al compilador que Circle
y Square
son tipos separados con campos kind
específicos, podemos escribir código TypeScript seguro que es prácticamente igual al JavaScript habitual, y el sistema de tipos hará el resto.
Nota adicional
Si pruebas a eliminar algunos return
dentro del switch
, verás que TypeScript puede ayudarte a evitar errores al detectar caídas accidentales a otras cláusulas.
Las uniones discriminadas no solo sirven para figuras geométricas. Son muy útiles para representar esquemas de mensajes en JavaScript, por ejemplo en comunicaciones cliente-servidor o en la gestión de estados.
El tipo never
Cuando reduces una unión mediante un análisis exhaustivo, puedes llegar a un punto donde no queda ninguna opción posible. En estos casos, TypeScript utiliza el tipo especial never
, que representa un estado que no debería existir.
never
es asignable a cualquier tipo, pero ningún otro tipo (excepto never
) es asignable a never
. Esto es útil para hacer comprobaciones exhaustivas, por ejemplo en switch
:
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;
}
}
Si luego añadimos un nuevo tipo, como un triángulo:
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;
// Error: 'Triangle' no es asignable a 'never'.
return _exhaustiveCheck;
}
}
El compilador nos avisará que no hemos manejado el nuevo caso "triangle"
, ayudándonos a mantener nuestro código seguro y exhaustivo.