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.


Ejemplo basico con padLeft

ts
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 tiene padding dentro de cada rama del if.

¿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

1. typeof

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:

ts
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:

ts
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

ts
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.

ts
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:

ts
function printAll(strs: string | string[] | null) {
  if (strs !== null) {
    // Ahora strs es string | string[]
  }
}

Usar != null filtra tanto null como undefined.


5. Narrowing con operador in

Se puede usar para detectar si un objeto tiene una propiedad:

ts
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:

ts
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.

ts
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.

ts
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:

ts
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:

ts
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

ts
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:

ts
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.

ts
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:

ts
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:

ts
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:

ts
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.

ts
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:

ts
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?

ts
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:

ts
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.

ts
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:

ts
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?

ts
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:

ts
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:

ts
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:

ts
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.