AboutOpinionesBlogLa Blockletter

Software Crafters® 2025 | Creado con 🖤 para elevar el nivel de la conversación sobre programación en español | Legal

Home » typescript » Union types y pattern matching con TypeScript.
Union types y pattern matching con TypeScript.

Union types y pattern matching con TypeScript.

Pattern Matching o búsqueda de patrones es típicamente usado por lenguajes de programación funcionales, veremos como aplicarlo en TypeScript.

Daniel García · Seguir12 min read ·

Al suscribirte a la newsletter comparto contigo los 10 libros más importantes de programación. Los que sin duda todo dev debería leer al menos una vez...

Muchos ya sabréis que el tipado estático del lenguaje de programación TypeScript permite declarar un tipo de dato como numérico, texto, booleano o de instancia de clase, por poner algunos ejemplos típicos. Lo que quizás no es tan conocido, seguramente porque no existe en los lenguajes más populares, es que TypeScript también soporta union types (o tipos unión traducido al español).

Conociendo los union types

Conceptualmente se parecen a los enum types (o tipos enumerados) pero con una importante vuelta de tuerca: permiten definir un tipo como una lista cerrada de valores y, a su vez, cada valor puede ser de un tipo diferente. Suena interesante, ¿no?

Veamos cómo definirlo:

let a: number | string;

Vemos que poniendo el símbolo "|" entre dos tipos le estamos indicando que la variable "a" puede ser de tipo numérica o de tipo texto. Podemos poner tantas opciones como queramos (más adelante veremos otros ejemplos).

Una vez declarado el tipo, vamos a ver qué podemos hacer con él:

let a: number | string;
a = 1; // a es un número. Es válido
a = "hello world"; // a ahora es texto. También es válido
a = true; // ¡Error! El compilador dice que a no puede ser true

No hay sorpresa. Al haber declarado el tipo de la variable a como numérica o de texto, podemos definirla con el valor 1 o el

"hello world"
. Pero al intentar usar valores de otro tipo, en este caso booleano, el compilador nos advierte de que hay un error de tipo y el código no compila.

Vale, y ahora que conocemos la sintaxis, nos preguntaremos, ¿y para qué sirve el tipo unión? Si buscamos en los objetivos de diseño del lenguaje TypeScript, encontraremos razones de peso: "Es un superconjunto de JavaScript que trata de evitar errores mediante los tipos y, al mismo tiempo, preservar el comportamiento original de JavaScript" . Por lo tanto, los union type son un mecanismo que permite especificar diferentes tipos para una misma variable, parámetro de una función o como resultado devuelto.

Veamos algunos ejemplos de uso de union type dentro del propio núcleo del lenguaje:

const someDate = new Date(1553444243368); // 2019/03/24 17:17 en formato epoch
const anotherDate = new Date("Mar 24, 2019"); // 2019/03/24 00:00 en formato inglés

const result: RegExpExecArray | null =  /\d+/.exec("123456");

Podemos observar que el constructor del tipo Date puede recibir un número, que sería el tiempo en formato epoch o un texto donde la fecha esté definida en inglés con un determinado formato. También vemos que la función exec (muy usada para validar texto con expresiones regulares) puede devolver un objeto especial con el resultado o null.

Cuando usamos TypeScript, el compilador también usa union types en las propias definiciones de la API del lenguaje para saber qué se puede hacer con los tipos base de JavaScript. Esta es parte de la definición de los tipos que vimos en el ejemplo anterior:

// extracto de typescript/lib/lib.es5.d.ts

interface Date {
new(value: number | string): Date;
}

interface RegExp {
exec(string: string): RegExpExecArray | null;
}

No hace falta entenderla en profundidad. Es solo una curiosidad y la prueba de que hasta el propio TypeScript define muchos tipos base de JavaScript con union types. De hecho, las librerías más conocidas hechas en TypeScript, como pueden ser react o angular, usan union types.

Caso práctico

Imaginemos que queremos hacer una librería con una función que dobla el valor que se le pasa. Y vamos a permitir que el parámetro pueda ser de tipo numérico, texto o lista (array). Con lo que hemos contado hasta ahora tendríamos algo así:

function double(value: number | string | any[]): number | string | any[] {
   if (typeof value === "number")
       return value * 2;
   if (typeof value === "string")
       return value.concat(valor)
   return [...value, ...value];
}

double(1); // 2
double("hello"); // "hello"
double([1,2,3]); // [1,2,3,1,2,3]
double(false); // Error. El argumento no se puede asignar
double({id: 1, name: "Pepe"}); // Error. El argumento no se puede asignar

Bien. Ya tenemos nuestra función polimórfica "double" operativa. Aunque funciona es un poco verbosa y personalmente prefiero que el código sea lo más conciso posible. Así que lo primero que vamos a hacer es mejorar la forma de declarar los tipos que acepta y devuelve. Para eso necesitamos introducir el type alias (o alias de tipo en español). TypeScript permite ponerle otro nombre a un tipo de datos. Así:

type <alias> = <tipo>;

Podemos definir el alias con un tipo cualquiera, incluso definido por el usuario como pueden ser interfaces, clases, union types u otros type alias.

Para nuestra función definiríamos el siguiente alias:

type DoubleType = number | string | any[];

Y ahora podemos sustituir el tipo de entrada y salida por el alias, quedando la definición más simple:

function double(value: DoubleType ): DoubleType {
   if (typeof value === "number")
       return value * 2;
   if (typeof value === "string")
       return value.concat(value)
   return [...value, ...value];
}

Podríamos quedarnos aquí. Pero ¿y si pudiéramos sustituir el código por algo más expresivo?

Pattern matching al rescate

Al igual que union type, el pattern matching es algo típicamente usado por lenguajes de programación funcionales y estáticamente tipados como Haskell, Scala o F#. Por lo que encontraremos muchas referencias a éstos y a su sintaxis. Pero no te asustes; el concepto es muy simple y en realidad ya lo conoces, más o menos:

let result: DoubleType;

switch (value) {
   case typeof value === "number":
result = value * 2;
break;
   case typeof value === "string":
result = value.concat(valor);
break;
   default:
result = [...value, ...value];
}

¿Qué es el pattern matching?

Es una estructura del lenguaje que nos permite comprobar un valor contra una serie de casos. Cuando un caso se cumple, se ejecuta la expresión asociada y se termina. Idealmente, los casos permiten especificar no solo valores constantes, si no también tipos, tipos con propiedades concretas o condiciones complejas. Conceptualmente, se parece a un switch mejorado, como en el ejemplo de arriba, que tiene una sintaxis no válida en TypeScript.

A pesar de que TypeScript no soporta pattern matching en su sintaxis, podemos recurrir a bibliotecas (libraries) para suplir su carencia. En nuestro caso vamos a usar el paquete de npm llamado x-match-expression.

import {match} from "x-match-expression";

function double(value: DoubleType): DoubleType {
   return match(value)
       .caseNumber(function (n) { return n * 2})
       .caseString(function (s) { return s.concat(s)})
       .default(function (array) { return [...array, ...array]});
}

Todavía lo podemos simplificar un poco más usando expresiones lambda en vez de funciones:

import {match} from "x-match-expression";

function double(value: DoubleType): DoubleType {
   return match(value)
       .caseNumber(n => n * 2)
       .caseString(s => s.concat(s))
       .default(array => [...array, ...array]);
}

El código ahora se ve más conciso, pero vamos a explicarlo detalladamente. Primero, importamos la función match, que nos va a permitir hacer el pattern matching en sí. A continuación, la invocamos pasándole el valor que queremos comprobar. Luego, definimos los casos caseNumber y CaseString. Cada caso comprueba si el valor es de un tipo concreto y, si lo és se ejecuta la expresión asociada al caso. Si nos fijamos, la expresión tiene para cada caso un parámetro del tipo que se está probando (esto se aprecia con un editor de código). Es decir, en el caseNumber tendríamos la certeza de que n es numérico. Este principio se aplica a los demás casos que tiene la librería. Finalmente, añadimos un caso por defecto (default), necesario para completar la expresión.

Aquí hay un editor online para hacer pruebas con todo lo anterior: https://stackblitz.com/edit/typescript-crd5ep

Lista de casos de uso de x-match-expression

Antes de ver la lista de casos, algunos conceptos generales:

  • Hay un caso por cada tipo primitivo de dato en JavaScript
  • Por cada case<<Type>>, hay un case<<Type>>If adicional que tiene un parámetro más llamado predicado, que sirve para hacer una comprobación extra.
  • Todos los casos reciben un parámetro llamado mapper, que es la función que se ejecuta o valor que se devuelve. Si se usa como función, recibe como parámetro el elemento con el tipo acorde al caso.
  • Para terminar el patrón y ejecutarlo, hay que finalizar con el caso default.

Esta es la lista de los casos de uso más comunes que tiene la librería:

  • case: es el comodín. Se le pasa un predicado cualquiera que nosotros definamos
  • **caseIntance:**comprueba si un elemento es instancia de clase o tiene una función como prototype (función constructora)
  • caseTrue: comprueba si un elemento es true
  • caseFalse: comprueba si un elemento es false
  • caseBolean: comprueba si un elemento es de tipo booleano
  • caseEqual: comprueba si un elemento es igual a otro (no hace comparación profunda)
  • caseNotEqual: comprueba si un elemento es distinto a otro (no hace comparación profunda)
  • caseNumber: comprueba si un elemento es de tipo numérico
  • caseAlmostEqual: comprueba si un elemento es un número aproximado a otro (útil para operaciones decimales)
  • caseNull: comprueba si un elemento es nulo
  • caseObject: comprueba si un elemento es de tipo Object
  • caseObjectLike: comprueba si un elemento tiene partes iguales a otro (mismos keys con los mismos valores)
  • caseObjectWithKeys: comprueba si un elemento es un objeto con ciertas claves
  • caseString: comprueba si un elemento es de tipo string
  • caseStringLike: comprueba si un elemento es de tipo string y cumple con una expresión regular
  • caseEmptyString: comprueba si un elemento es un string vacío
  • caseDate: comprueba si un elemento es de tipo Date
  • caseArray: comprueba si un elemento es de tipo Array
  • caseEmptyArray: comprueba si un elemento es de tipo Array vacío

Para mí los más utilizados son caseInstance y caseInstanceIf, que permiten usar union types con clases. Hay más casos. Te animo a jugar con la librería y descubrirlos.

Uniones discriminadas

Hay otro concepto en TypeScript relacionado con los union types llamado discriminated unions (o uniones discriminadas en español). Básicamente son tipos que tienen alguna propiedad común, pero con un valor conocido diferente para cada posible tipo y se unen en un tipo unión. Como puede sonar a chino, vamos a poner un poco de código para aclararlo:

interface Person {
type: "person";
	name: string;
	age: number;
}

interface Animal {
	type: "animal";
	name: string;
	species: string;
	legs: number;
}

type Entity = Person | Animal;

function doSomething(entity: Entity) {
	switch (entity.type) {
		case "person":
			console.log(entity.name, entity.age);
			break;
		case "animal":
			console.log(entity.name, entity.species, entity.legs);
			break;
	}
}

En nuestro código, definimos dos interfaces, Person y Animal. Cada una tiene el atributo type con un valor de texto diferente. Después los unimos con el type alias Entity como union type.

Con esta combinación, podemos ver que en la función

doSomething(...)
tenemos un
switch
que puede detectar qué tipo es gracias al discriminador common.type. Por supuesto, este discriminador se puede llamar de cualquier forma (no tiene que ser type). Y lo más importante: el compilador de typescript es lo suficientemente inteligente para entender que en cada caso tenemos tipos diferentes y por lo tanto tienen atributos diferentes. Así,
entity.name
lo vas a encontrar en cualquier caso, pero dentro del caso "person" solo encontrarás el atributo
entity.age
. Y en el caso "animal", encontrarás los atributos
entity.species
y
entity.legs
. ¡Genial! ¿verdad?

Como el ejemplo para union types, vamos a ver cómo hacemos el pattern matching de este nuevo caso:

function doSomething(entity: Entity) {
return match(entity)
	.case(e => e.type === "person", e => {
		console.log(e.name, e.age);
	})
	.case(e => e.type === "animal", e => {
		console.log(e.name, e.species, e.legs);
	})
	.default(e => {
		throw new Error("unknown type " + e.type);
	});
}

Usamos el comodín case que toma un predicado cualquiera que nosotros definamos. Así podemos comprobar si entity.type es "person" y si entity.type es "animal". Posiblemente, para este caso no nos hemos simplificado demasiado el código respecto al switch, ¿pero qué pasa cuando hay más tipos discriminados? ¿O cuando podríamos querer tener comprobaciones más finas o lógicas adicionales?

function doSomething(entity: Entity) {
return match(entity)
	.case(e => e.type === "person" && e.age > 18, e => {
		console.log("adulto", e.name, e.age);
	})
	.case(e => e.type === "person" && e.age <= 18, e => {
		console.log("niño", e.name, e.age);
	})
	.case(e => e.type === "animal", e => {
		console.log(e.name, e.species, e.legs);
	})
	.default(e => {
		throw new Error("unknown type " + e.type);
	});
}

¡Ahora sí! Hemos añadido un caso adicional para Person, donde estamos "extendiendo" el caso persona original para separarlo en persona adulta o niño. Por supuesto, podemos añadir aún más condiciones, como si el animal es perro, gato, etc. y hacer un código más potente.

Ahora compara este código con lo que habría que hacer con un

switch
o, peor aún, con varios
if/else
. Con pattern matching y una simple estructura de tipo, hemos mejorado mucho la legibilidad. Y también la comprobación sobre objetos complejos.

En este enlace puedes encontrar un ejemplo: https://stackblitz.com/edit/typescript-mngj7v

¿Dónde usar union types y pattern matching?

Conviene aplicarlos en situaciones donde hay varios casos a controlar y cuando el número de casos es fijo (no expansible) o controlado:

Ejemplos del core:

  • El constructor de Date
  • JSON.parse
  • Math.* (sin, cos…)
  • Para validaciones

Ejemplos avanzados:

  • Para hacer parsers de DSL
  • Para el control de ficheros CSS, JavaScript, etc.
  • Para controlar el tipo devuelto en un servicio de backend, donde suele haber tipos como Success, Failure u otros valores devueltos específicos a cada función
  • Para temas de seguridad donde puedes tener tipos como Anonymous, Authorized, Admin, etc., con propiedades diferentes
  • Para tener variantes de un tipo, como Currency, donde puedes tener EUR, USD, etc., cada uno con información específica al tipo de moneda

Ejemplos específicos:

  • En librerías de desarrollo como react, como vemos en las props, en elementos del DOM o en los eventos del DOM
  • En APIs de servicios externos como Stripe, Nest, etc., cuando se tiene que controlar todas las opciones y parámetros de configuración

Ejemplos que NO son buenos:

  • Cuando tus tipos son una jerarquía controlada, pero no tiene sentido tener versiones discriminadas de los tipos. En ese caso, lo mejor es usar interfaces y herencia clásica para poder aprovechar al máximo la potencia de la herencia
  • Cuando "no hay mucho en común". No tiene sentido usar un union type que sea "string | Number | Person | Date | …" ya que tiene poca cohesión

Si quieres jugar con la librería y ver los ejemplos completos, te dejo este enlace:

  • Ejemplos: https://github.com/deerawan/x-match-expression/tree/master/examples

Conclusión

Acabamos de ver cómo los union types y el pattern matching pueden ayudarnos a controlar el flujo de nuestra aplicación. Son una herramienta poderosa que sirve para hacer código más robusto, más legible y flexible. También nos ayuda a reducir la duplicación de código teniendo todas las reglas en un solo lugar.

Además, en este artículo hemos presentado funcionalidades de programación funcional que con la llegada de TypeScript se hacen más accesibles en un entorno como JavaScript. Así que la próxima vez que necesites comprobar un conjunto de casos, ya sabes qué tener en cuenta.

Si te ha gustado este artículo, compártelo en redes sociales para que llegue a más gente.

Quizás te interese

Introducción a la programación reactiva con RxJS

Introducción a la programación reactiva con RxJS

Tutorial de Typescript, el javascript que escala. Introducción.

Tutorial de Typescript, el javascript que escala. Introducción.

Al suscribirte a la newsletter comparto contigo los 10 libros más importantes de programación. Los que sin duda todo dev debería leer al menos una vez...