Mantenimiento de invariantes en TypeScript

Escribía hace un par de semanas sobre las diferencias entre tipado estructural y tipado nominal y comentaba que las características de los sistemas de tipos tienden a condicionar la forma en que se programa y favorecen unos estilo u otros.

En este post quiero analizar algunas implicaciones del sistema de tipado de estructural que utiliza TypeScript centrándome en un aspecto concreto: el modelado de invariantes asociados a un tipo de datos.

Modelando datos

Suponte que tienes la típica aplicación de seguimiento de zombies en la que tienes el nombre del zombie y los días que lleva (no) muerto. Puedes usar una estructura de datos sin esquema, que es algo bastante idiomático en Javascript:

const genaro = { name: 'Genaro', daysDead: 2 }

Hay un pequeño problema. Esto es poco descriptivo, y si voy a tener muchos zombies, sería bueno poder saber qué es un zombie. Para eso puedo declarar un tipo:

type Zombie = { name: string; daysDead: number }

// O si lo tuyo son los interfaces
interface Zombie = { name: string; daysDead: number }

Vamos bien. Hemos marcado el aspecto que tienen los zombies. Eso además me permite declarar cosas de tipo Zombie y evitar asignarles cosas indebidas:

let genaro: Zombie = 'Soy un catamarán';  // Error string no es asignable a zombie
let genaro: Zombie = { name: 'Genaro', daysDead: 2 }; // OK

Todavía no tenemos ninguna forma específica de construir objetos de tipo Zombie, pero aprovechando el tipado estructural que explicaba en el post anterior, puesto asignarle un objeto literal a una referencia a zombie siempre que la estructura sea compatible. Curiosamente, TypeScript es un poco más exquisito con los objetos literales que con otro de tipo de valores, y no te permite hacer esto:

let genaro: Zombie = {
  name: 'Genaro',
  daysDead: 2,
  eyeCount: 1
}; // Error eyeCount no está en Zombie

Decía que es curioso, porque si en lugar de asignar directamente el objeto literal pasa antes por una variable, puedes hacerlo sin problemas:

let other = { name: 'Genaro', daysDead: 2, eyeCount: 1 };
let genaro: Zombie = other; // OK, ya no nos molesta eyeCount

En un comportamiento un tanto arbitrario y bastante discutible, pero resulta útil para detectar errores tipográficos cuando trabajas con tipos que tienes parámetros opcionales:

type Config = { width?: number; height?: number };
let config: Config = { widht: 140, heigth: 50 } // Error

Si no existiera este comportamiento especial para objetos literales, el codigo anterior no daría ningún error porque tanto width como height son opcionales, cuando en realidad lo más probable es que nos hayamos equivocado al escribirlos.

Pero volvamos a los Zombies. Hemos definido la estructura que tiene el tipo Zombie, pero en realidad no todos los posibles pares de string y number son valores válidos para ese tipo. Para que un Zombie sea válido debe tener un nombre y, obviamente, el número de días que lleva (no) muerto no puede ser inferior a 0, porque si lo fuera no sería (todavía) un zombie. Esto constituye un invariante (sencillo) que deben cumplir todos valores del tipo Zombie. Aunque en TypeScript no podemos (o mejor dicho, yo no sé hacerlo) codificarlo en el sistema de tipos, podemos tratar de controlarlo al construir nuestros Zombies.

Con lo que tenemos hasta ahora, poco podemos hacer para garantizar ese invariante. No hay nada que nos impida tener una referencia a un Zombie inválido:

let genaro: Zombie = { name: '', daysDead: -3 }; // OK

Para mejorar un poco la situación, podemos intentar utilizar una función que nos ayude a construir Zombies y verifique sus valores:

const makeZombie = (name: string, daysDead: number) => {
  if (!name) throw Error('Zombie cannot be anonymous');
  if (daysDead < 0) throw Error('Zombie cannot be alive');
  return { name, daysDead };
}

No está mal, pero esto sólo funciona si me acuerdo de usar la función siempre que vaya a construir Zombies. Además, nuestro objeto Zombie sigue siendo mutable, por lo que una vez construido, aunque hubiésemos pasado por la función makeZombie, alguien podría estropearlo modificándo sus propiedades. Este último punto es fácil de solucionar definiendo el tipo como type Zombie = { readonly name: string; readonly daysDead: number }, pero lo de olvidarnos de usar la función es más complicado de resolver.

A estas alturas, uno podría pensar que el problema viene por haber definido Zombie como un type o un interface en lugar de usar una clase como dios manda, que para eso las clases son La Forma Correcta™ de hacer las cosas:

class Zombie {
  constructor(readonly name: string, readonly daysDead: number) {
    if (!name) throw Error('Zombie cannot be anonymous');
    if (daysDead < 0) throw Error('Zombie cannot be alive');
  }
}

Bien. Esto mejora las cosas. Ahora cuando construimos un Zombie estamos seguros de que los valores que contiene son correctos y el compilador verificará que los valores de un Zombie no pueden ser modificados una vez que los hemos construido. Es decir, podemos estar seguros de que sí tenemos una referencia a un objeto Zombie, éste es correcto. Sólo hay un pequeño problema. Que es mentira. TypeScript sigue permitiendo esto:

let genaro: Zombie = { name: '', daysDead: -3 };

Aunque usemos una clase para definir nuestro tipo, TypeScript sigue utilizando tipado estructural, por lo que podemos saltarnos todos los invariantes que tengamos en la clase. Y ya no es sólo con objetos anónimos, sino con otro tipo de objeto que se parezca:

type JSFramework = { name: string, daysDead: number; url: '' };
let durandal: JSFramework: { name: 'Durandal', daysDead: 843, url: 'http://durandal.es' };

let genaro: Zombie = durandal; // OK

Aunque no tienen nada que ver, podemos asignar frameworks de Javascript a Zombies y aquí nadie se queja, aunque sus invariantes pudieran no sean compatibles. Al no poder optar por aplicar o no la equivalencia estructural entre tipos, estamos bastante limitados.

Una alternativa rebuscada

Si te empeñas mucho, puedes aplicar algunas técnicas avanzadas ñapas que, aunque no llegan a impedir la asignación, sí hacen que sea más complicado hacerla por error. Por ejemplo, podríamos definir Zombie así (requiere TypeScript 2.7+):

// Zombie.ts
const secret = Symbol();
export default class Zombie {
  private [secret]: 'type does not matter';
  // .. mismo constructor que antes
}

Construimos un símbolo que mantenemos privado al módulo en el que está definida nuestra clase Zombie y lo utilizamos como identificador de una propiedad privada a la clase. Cuando el sistema de tipos de TypeScript valida si dos tipos son compatibles, tiene en cuenta tanto las propiedades públicas como las privadas. Por ello, para poder asignar un objeto anónimo (o de cualquier otra clase) a una referencia de tipo Zombie, será necesario que incluya esta nueva propiedad.

La gracia es que esa propiedad no está definida con un nombre normal, sino con un símbolo, y para poder crear otro objeto que tuviera una propiedad como esa necesitaríamos tener acceso al símbolo, pero éste es privado al módulo donde se define Zombie. A efectos prácticos, eso hace que no podamos crear objetos compatibles con Zombie si no es usando la clase Zombie. Vamos, que impedimos que esa clase forme parte de juegos estructurales.

Esta propiedad, a la que no asignamos ningún valor, no aparecerá en el código Javascript compilado, por lo que no tendrá ninguna implicación a nivel de rendimiento, consumo de memoria y todas esas cosas que tanto preocupan a los microoptimizadores.

Podríamos incluso llevar esto al extremo y añadir en todas nuestras clases una propiedad secret con símbolo distinto para evitar se aplique el tipado estructural entre clases. Con ello podríamos hacer que nuestros interfaces sí que funcionaran con tipado estructural pero nuestras clases no.

Por desgracia, esto no deja de ser ir en contra del lenguaje, usar construcciones no idiomáticas y, contribuir a crear confusión a los que tengan que trabajar con nuestro código. También debemos contar con que no todo el código que usamos es nuestro y podemos modificarlo con estas "técnicas", así que queramos o no tendremos que acabar por acostumbrarnos a la forma en que funciona TypeScript (o cambiar de lenguaje).

Conclusiones

TypeScript está diseñado para integrarse muy estrechamente con Javascript, y por ello su sistema de tipos está pensando para poder soportar los patrones de uso más habituales en Javascript. Teniendo en cuenta que Javascript es un lenguaje en el que se ve normal que una función pueda recibir como parámetro un number, un string, undefined o un señor de Cuenca, es lógico que el sistema de TypeScript favorezca la flexibilidad frente a la rigidez corrección. No es sólo que como sistema de tipos suene o no, sino que hay unas cuantas peculiaridades que pueden dar lugar a situaciones incómodas.

Una de las implicaciones claras de todo esto es que si estás acostumbrado a aprovechar el sistema de tipos para mantener invariantes, la cosa se complica. Puedes dar algún rodeo como el que he expuesto antes, pero no creo que merezca la pena.

Personalmente, trato de ser disciplinado, huir de las clases (ya que garantizan menos de lo que parecen) y apostar por tipos/interfaces y funciones. Si el tipo es medianamente complicado y/o tiene algún invariante que mantener, uso una función para construirlo. Para mi la clave es que me resulte más cómodo usar la función que crear un objeto literal que (potencialmente) pueda violar invariantes. Si es así, al final me voy a regir por la ley del mínimo esfuerzo y acabaré usando la función y minimizando la posibilidad de violar el invariante.

7 comentarios en “Mantenimiento de invariantes en TypeScript

  1. Siempre podrías pedirles que implementaran el tipo Natural o traer el range de Ada.

    O quizá podemos pedir la luna…


    // Just wishing on a bright future. None of this is real, o.c.:
    type natural extends number where (this.value >= 0);
    type NonEmptyString extends string where (this.value.length >= 0);
    type Zombie = { name: nonEmptyString; daysDead: natural }:

    let eulogio: Zombie = { name: 'Eulogio', daysDead: 3 }; // OK
    let genaro: Zombie = { name: '', daysDead: -3 }; // MEC!!

    P.D. En realidad… Si Durandal murió, el zombie sería Aurelia, no? O Aurelia sería una especie de monstruo de Frankenstein? O cómo se llama cuando coges trozos de un zombie, haces otra cosa y le das vida?

  2. Con el sistema de tipos actual de TypeScript podrías llegar a modelar números naturales (e incluso sus operaciones) de forma segura (y farragosa y poco práctica): https://github.com/Microsoft/TypeScript/issues/14833

    En realidad la opción buena sería esa, llevarte al sistema de tipos el mantenimiento de invariantes en lugar de hacerlo en tiempo de ejecución en base a la ejecución de los constructores (ya fueran de clases o funciones).

    Supongo que en un sistema de tipos lo bastante avanzando es posible llegar lejos, pero diría que hay escenarios que requieren validar en tiempo de ejecución. Por ejemplo, cuando las reglas dependen del contenido de algo externo, como un fichero de configuración o una base de datos.

    PD: «cómo se llama cuando coges trozos de un zombie, haces otra cosa y le das vida?» ¿.NET Core.?

  3. Bueno, en el caso de haskell son macros, y en el caso de f# no son muy diferentes. La ventaja (e inconveniente en un momento dado) de las mismas, en general, es la inmediata: generar codigo al vuelo, siempre, que no te crea un artefacto separado que mantener

  4. Buenas tardes.

    El tipado dependiente y el tipado fantasma. Tecnicas ya comentadas en este blog. Serian apropiadas para este tema de invariantes ??
    Es realista intentar aplicarlas en TypeScript ??

    Saludos.

Comentarios cerrados.