Tipos Suma en TypeScript

A veces da la sensación de que TypeScript se usa «sólo» como una forma de tener Javascript con intellisense y errores de compilación. La verdad es que, sólo por eso, ya puede merecer la pena, pero si nos quedamos ahí nos estamos perdiendo parte de las ventajas que nos puede aportar a la hora de diseñar soluciones.

Quizá el sistema de tipos de TypeScript no sea el mejor del mundo y presente sus problemas a la hora de mantener invariantes o no tenga toda la solidez que a uno le pudiera gustar, pero eso no es óbice para que ofrezca alternativas de diseño interesantes, sobre todo comparado con las que solemos ver en lenguajes como C# o Java.

En este post vamos a ver cómo podemos aprovechar varias características del sistema de tipos, concretamente los tipos literales y los tipos unión, para conseguir un diseño más seguro.

Tipos literales

Se le llama tipo literal en TypeScript a aquel tipo que contiene un único valor de tipo string, number, boolean o enum. Por ejemplo:

type Two = 2;
type Duck = 'Duck';

const x: Two = 2; // OK
const y: Two = 3; // Error

const a: Duck = 'Duck'; // OK
const b: Duck = 'Fox'; // Error

A primera vista no parece algo muy útil porque todas las variables que definamos del tipo tendrán el mismo valor, pero cuando los combinamos con otros tipos la cosa mejora.

Las reglas de inferencia de tipos para los tipos literales son un poco confusas (y han cambiado alguna vez a lo largo de la vida de TypeScript), por lo que hay que andarse con un poco de ojo:

let x = 2; // el tipo inferido para x es "Number"
const x = 2; // el tipo inferido para x es 2

Por supuesto, un valor de un tipo literal es asignable a valores del tipo «general», pero no a la inversa:

const x: Two = 2;
const y: Number = x; // OK
const z: Two = y; // Error

Tipos unión

En TypeScript podemos definir un tipo formado por la unión de otros tipos, es decir, a una variable del tipo unión podemos asignarle cualquier valor de cualquiera de los tipos que forman la unión:

type StringOrNumber = String | Number;

let a: StringOrNumber = 2; // OK
let b: StringOrNumber = 'Mar'; // OK
let c: StringOrNumber = null; // Error

Como no, nuestros tipos literales pueden formar parte de uniones:

type DuckOrTwo = 'Duck' | 2;

let a: DuckOrTwo = 2; // OK
let b: DuckOrTwo = 'Duck'; // OK
let c: DuckOrTwo = 142.12; // Error

Esto facilita interoperar con apis Javascript donde es frecuente que algún parámetro pueda tomar un conjunto de valores primitivos. Un caso típico sería el método addEventListener para registrar manejadores de eventos, al cual se le pasa el nombre del evento para el que se quiere registrar el manejador.

Tipos suma

Juntando los tipos literales y los tipos unión podemos construir el equivalente a tipos suma. Estos tipos pueden almacenar información de distintos tipos, y existe una forma de detectar cuál de los posibles tipos almacenan.

Dicho así suena un poco enrevesado, pero es fácil entenderlo con un poco de código. Imagina que necesitas representar un valor opcional. Para ello hace falta indicar de alguna forma la ausencia de valor, algo que muchas veces se hace utilizando null o undefined, pero esa alternativa no siempre es aconsejable. Podemos mejorarlo haciendo algo así:

type Optional<T> = { empty: true } | { empty: false, value: T };

function f(x: Optional<number>) {
  if (x.empty === true) {
    // Tipo inferido { empty: true }
    console.log(x.value); // error de compilación
  }
  else {
    // Tipo inferido { empty: false, value: number }
    console.log(x.value * 2);
  }
}

Es un poco feo tener que escribir el x.empty === true, pero sin eso el sistema de tipos de TypeScript no es capaz de acotar el tipo correcto en cada rama del if.

Todo esto podemos aplicarlo a cosas algo más interesantes que Optional. Veamos otro ejemplo en el que tenemos usuarios con una dirección de correo asociado y queremos que no se les pueda enviar emails hasta que la dirección de correo haya sido verificada.

Modelar esto en C# con una cierta seguridad de tipos es relativamente incómodo y nos obliga a utilizar varias clases y alguna relación de herencia por el camino. En TypeScript es bastante sencillo:

type VerifiedEmail = { verified: true, address: string };
type UnverifiedEmail = { verified: false, address: string };
type Email = VerifiedEmail | UnverifiedEmail;

type User = { name: string, email: Email };

function sendEmail({ address }: VerifiedEmail) {
    // TODO: Enviar email
}

function verifyEmail({ address }: UnverifiedEmail) {
    // TODO: Verificar el email
}

let u: User = getUserFromSomewhere();

sendEmail(u.email); // Error. Email podría no estar verificado

if (u.email.verified === true)
    sendEmail(u.email);
else
    verifyEmail(u.email);

De esta forma es fácil crear tipos más restrictivos que nos permitan limitar las operaciones disponibles en cada caso y nos obliguen a tener en cuenta todos los escenarios posibles.

En realidad, como decíamos antes, lo único que estamos haciendo es implementar tipos suma de una forma un tanto rebuscada, usando un valor dentro del tipo como discriminador en lugar de usar constructores como haríamos, por ejemplo, en Haskell donde nuestro tipo Email se definiría así:

data Email = VerifiedEmail address | UnverifiedEmail address

En lugar de utilizar un flag para distinguir entre los dos estados de Email se utilizan distintos constructores, VerifiedEmail y UnverifiedEmail para diferenciar un tipo de correo de otro.

Conclusión

TypeScript no es la panacea y tiene un montón de cosas discutibles (soy el primero que se suele quejar de ello), pero también ofrece posibilidades interesantes.

Desgraciadamente, en el mundo del frontend es muy frecuente «no diseñar» y limitarse a aplicar los patrones indicados por el framework o librería de turno (react, vue, redux, angular…). Eso hace que TypeScript se vea sólo como una forma de tipar estáticamente las llamadas a estas herramientas y no se intente aprovechar realmente la capacidad de diseño que permite su sistema de tipos.

Como vimos al hablar de las clases como code smell, es importante utilizar cada lenguaje sacando partido a sus características en lugar de limitarse a traducir de un lenguaje a otro. Si tu uso de TypeScript se reduce a emplear las mismas técnicas que en Javascript (pero con intellisense) o los mismos diseños que en C# (pero ejecutándose en un browser), te estás perdiendo gran parte de sus ventajas.