Software Crafters® 2025 | Creado con 🖤 para elevar el nivel de la conversación sobre programación en español | Legal
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).
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.
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?
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]; }
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
Antes de ver la lista de casos, algunos conceptos generales:
Esta es la lista de los casos de uso más comunes que tiene la librería:
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.
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
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:
Ejemplos avanzados:
Ejemplos específicos:
Ejemplos que NO son buenos:
Si quieres jugar con la librería y ver los ejemplos completos, te dejo este enlace:
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.