Tipos de Datos Algebraicos Sum en TypeScript

Una de los conceptos más útiles que he aprendido sobre programación funcional en el pasado son los tipos de datos algebraicos sum o uniones discriminadas, y me gusta combinarlo con programación orientada a objetos porque me resulta de mucha utilidad.

Intento utilizarlos cuando es posible, con cualquier lenguaje de programación que utilizo.

Desde hace un tiempo estoy haciendo algunas cosas con TypeScript y estoy utilizando tipos de datos algebraicos sum para modelar mi el dominio.

En este artículo me gustaría compartir como podemos implementarlos en TypeScript.

Conocimientos previos

Si no conoces qué es un tipo de dato algebráico sum te recomiendo que previamente leas esta introducción que escribí hace tiempo y este artículo sobre como implementarlos en Kotlin y Swift que escribí en el blog de Kirei Studio.

En JavaScript no existe de forma nativa en el lenguaje soporte para Sum Types, existen algunas librerías de Node.js que vienen con monadas como Option, Either y te permiten crear tus propios Sum Types pero en este artículo vamos a ver como puedes definirte tus propios tipos sum utilizando TypeScript directamente.

Para poder definir sum types en TypeScript se utilizan un conjunto de conceptos que vamos a repasar.

Union Types

Mediante union type podemos acotar el tipo de una variable o argumento a unos tipos concretos.

La sintxis es asi:
variable: type1 | type2

Un ejemplo muy sencillo sería si tenemos que imprimir el contenido de un argumento y este puede ser una array de strings o un string directamente.

function print(unionText: string | string[]) {
    let text = "";

    if (typeof unionText === 'string') {
        text = unionText;
    }

    text = unionText.join(' ');
    
    console.log(text);
}

print('hello union example');
print(['hello', 'union', 'example']);

print(20); // Error: Argument of type '20' is not assignable to type 'string | string[]'

Type Alias

Los alias de tipo crean un nuevo nombre para un tipo.

Se puede crear un alias para cualquier cosa como una función, objeto literal, arrays o incluso un tipo primitivo aunque su utilidad sea cuestionable.

//object properties
type Product = { name: string, price: number };
let p: Product = {price: 100, name: 'Monitor'};

//function
type StringRemover = (input: string, index: number) => string;
let remover: StringRemover = function (str: string, i: number): string {
    return str.substring(i);
}
let s = remover("Hi there", 3);

//array
type State = [string, boolean];
let a: State = ["active", true];

//Primitive
type Name = string;

String Literal Types

Son tipos string que tienen asignado un valor de forma literal en la propia definición.

type Easing = "ease-in" 

Los tipos string literales junto con los tipos númericos literales y los miembros de enum se conocen como tipos singleton en TypeScript.

Types Guards

Un type guard es una expresión que realiza una validación tipo en runtime. Es útil trabajando con union types para verificar el tipo concreto en runtime.

Existen varios tipos de type guards

Definidos por el usuario

Consiste en una función que devuelve un predicado de tipo.

El cuerpo de la función puede ser un cast al tipo e intentar acceder a un elemento comparando con undefined.

function isFish(pet: Fish | Bird): pet is Fish {
    return (pet as Fish).swim !== undefined;
}

typeof

En lugar de crear una función, typeof puede ser utilizado directamente.

Podemos utilizar typeof con tipos primitivos como number, string, boolean.

typeof x === "string"

instanceof

Es similar a typeof pero podemos utilizarlo con tipos personalizados

interface Padder {
    getPaddingString(): string
}

class SpaceRepeatingPadder implements Padder {
    constructor(private numSpaces: number) { }
    getPaddingString() {
        return Array(this.numSpaces + 1).join(" ");
    }
}

class StringPadder implements Padder {
    constructor(private value: string) { }
    getPaddingString() {
        return this.value;
    }
}

function getRandomPadder() {
    return Math.random() < 0.5 ?
        new SpaceRepeatingPadder(4) :
        new StringPadder("  ");
}

// Type is 'SpaceRepeatingPadder | StringPadder'
let padder: Padder = getRandomPadder();

if (padder instanceof SpaceRepeatingPadder) {
    padder; // type narrowed to 'SpaceRepeatingPadder'
}
if (padder instanceof StringPadder) {
    padder; // type narrowed to 'StringPadder'
}

Para utilizar instanceof el tipo necesita ser una clase o una función constructura. En el caso de interfaces y objetos literales no funciona, en este caso necesitarías crearte type guard mediante una función y un predicado de tipo. (type guard definido por el usuario)

Sum Types

Combinando singleton types, union types, type guards, y type alias podemos crear tipos sum algebraicos.

Recordad que un tipo sum se compone de una serie de subtipos donde el valor final del tipo solo puede ser uno de los subtipos definidos.

Veamos un ejemplo básico:

interface Approved {
    kind: "approved";
}
interface Rejected {
    kind: "rejected";
    reason: String;
}

type OrderStatus = Approved | Rejected;

En otros lenguajes como Kotlin donde los Sum types se crean utilizando sealed class que basa en la herencia, la discriminación la realiza el lenguaje automáticamente por nosotros.

En typescript como podemos ver, necesitamos tener una propiedad como Kind para realizar la discriminación. Mediante esta propiedad sabemos si el resultado es de un tipo o de otro.

Expresiones tipo match

Una de las ventajas de utilizar tipos sum es que al componerse de un set de restringido de posibles opciones, en aquellas partes de nuestro código donde estemos utilizando expresiones tipo match utilizando when, switch o algo similar, la compilación fallará en caso de que no sea exhaustiva, es decir, falte alguna rama por definir.

function getOrderNotification(orderStatus:OrderStatus): String{
     switch (orderStatus.kind) {
        case "approved": return "The order has been approved"
        case "rejected": return "The order has been 
                         rejected." + "reason:" + orderStatus.reason
     }
}

Switch realiza smart cast, de forma que en el caso de estado rechazado podemos utilizar la propiedad reason sin necesidad de realizar un cast expecífico.

Para poder realizar chequeo exhaustivo en TypeScript tenemos que compilar con la opción --strictNullChecks.

En este modo si el Switch no es exhaustivo TypeScript interpreta que el return debería ser String|undefined y fallara el transpilado a JavaScript.

Conclusiones

Hemos visto como podemos crear tipos algebraicos sum en TypeScript.

Utilizar tipos sum para modelar el dominio puede ser muy interesante en algunos escenarios y en TypeScript podemos conseguirlo combinando singleton types, union types, type guards, y type alias.