TypeScript: varianza y solidez

Hace un año escribía sobre las diferencias entre los sistemas de tipos nominales y los sistemas de tipos estructurales. También explicaba por qué el tipado estructural que utiliza Typescript puede suponer un problema a la hora de mantener invariantes en el modelo de datos de una aplicación.

En este post quiero retomar el tema centrándome en las peculiaridades que presenta el sistema de tipos de Typescript a la hora de decidir si el tipo de dos funciones es estructuralmente compatible y las implicaciones que ello tiene. Por el camino pasaremos de puntillas por conceptos como la varianza de tipos y la solidez (soundness) de un sistema de tipos.

Tipado nominal y tipado estructural

No me voy a extender en este punto porque ya lo traté con más detalle, pero vamos a recordar las características básicas de cada sistema de tipos.

En un sistema de tipos nominal sólo podemos hacer asignaciones entre referencias del mismo tipo o que implementen un tipo común (una clase base o interfaz, por ejemplo). El nombre define la identidad del tipo, y si tenemos dos tipos diferentes, aunque tengan exactamente la misma estructura interna, no son compatibles.

En los sistemas de tipos estructural es la estructura del tipo la que define la compatibilidad. Eso permite que podamos realizar asignaciones entre cosas que, aunque a priori están definidas con tipos diferentes, tengan igual estructura (o al menos parecida).

Compatibilidad de funciones en TypeScript

Ya he mencionado antes que TypeScript utiliza tipado estructural para decidir la compatibilidad entre tipos pero, ¿cuándo se considera que dos funciones son compatibles?

Como era de esperar, esto va a depender tanto del tipo del valor de retorno de la función y los parámetros, como del número de los mismos.

Empezando por el valor de retorno, podemos tener casos como esto:

type ReturnsVoid = (x: string) => void;
type ReturnsBoolean = (x: string) => boolean;
type ReturnsBooleanOrString = (x: string) => boolean | string;

let f1: ReturnsVoid;
let f2: ReturnsBoolean;
let f3: ReturnsBooleanOrString;

// OK: f1 no devuelve nada, nos da igual que f2 devuelva algo
f1 = f2;

// Error: f1 no devuelve nada pero f2 debe devolver boolean
f2 = f1; 

// OK: f2 devuelve boolean, que es válido para boolean|string
f3 = f2;

// Error: f3 puede devolver string, que no es válido para boolean
f2 = f3;

Podemos asignar una función f1 a otra f2 si el tipo de retorno de f1 es más específico (es un subtipo) que el tipo de retorno de f2. Si lo piensas un poco, tiene sentido, porque los consumidores de f2 están preparados para trabajar con todos sus posibles valores de retorno, y los posibles valores de retorno de f1 son un subconjunto de los de f2. Técnicamente, esto se conoce como covarianza, y podemos decir que las funciones con covariantes con respecto al tipo de retorno.

¿Qué ocurre con los parámetros? Pues cabría esperar que más o menos lo contrario, pero no:

type AcceptsBoolean = (x: boolean) => void;
type AcceptsBooleanOrString = (x: boolean | string) => void;

let f1: AcceptsBoolean;
let f2: AcceptsBooleanOrString;

// OK: f2 trata boolean y string, y a través de f1 sólo llegará boolean
f1 = f2;

// OK: ¿Cómo? ¿Esto funciona?
f2 = f1;
// No, explota en tiempo de ejecución. Pero TypeScript te deja.
f2("hola");

En los lenguajes de programación sensatos otros lenguajes de programación se permite la primera asignación porque el tipo del parámetro de f1 es más restrictivo que el de f2, así que cuando se vaya a usar a través de la referencia f1 todos los posibles valores que se pasen serán válidos para f2. El nombre técnico de eso es contravarianza y diríamos que las funciones son contravariantes con respecto al tipo de sus parámetros.

Sin embargo, TypeScript permite también el segundo caso, que podría dar lugar a un error en tiempo de ejecución si usamos la referencia f2 para invocar f1 pasándole como parámetro un string. Eso se conoce como bivarianza y, aunque tiene su razón de ser en TypeScript, la verdad es que no me convence mucho. Por suerte en versiones recientes de TypeScript es posible desactivar este comportamiento con el parámetro strictFunctionTypes.

Todo esto de la covarianza y contravarianza tiene su definición formal y va un poco más allá de lo que hemos visto aquí.

Si pasamos a analizar la compatibilidad de funciones desde el punto de vista del número de argumentos, encontramos los siguientes casos:

type OneArg = (x: number) => void;
type TwoArgs = (x: number, y: number) => void;
type Optional = (x: number, y?: number) => void;
type Rest = (x: number, ...y: number[]) => void;

let f1: OneArg;
let f2: TwoArgs;
let f3: Optional;
let f4: Rest;

// OK: Al invocar a través de f2, f1 recibirá 
//     un segundo parámetro que puede ignorar
f2 = f1;

// Error: Al invocar a través de f1, f2 sólo recibirá 
//        un parámetro de los 2 que requiere
f1 = f2;

// OK: Parámetros opcionales y rest se tratan igual
f3 = f4;
f4 = f3;

// OK: ¿Cómo? ¿Esto funciona?
f3 = f2;
// No, esto puede explorar en tiempo de ejecución
// porque está invocando f2 con un sólo parámetro
f3(2) 

Una función f1 que recibe menos parámetros, puede ser asignada a otra función f2 que recibe más. Esto tiene (cierto) sentido, porque cuando la invoquemos a través de la referencia f2 se le pasarán parámetros adicionales que la función ignorará. El caso opuesto, por suerte, no está permitido, porque nos faltarían parámetros en la invocación.

Desgraciadamente, en cuanto metemos parámetros opcionales la cosa vuelve a desmadrarse y se permiten asignaciones que pueden provocar problemas en tiempo de ejecución.

Las consecuencias de todo esto

Todo esto puede parecer muy rebuscado y que nunca pasa en la vida real, pero en cuanto empiezas a pasar funciones como parámetros de otras funciones (algo muy habitual en código javascript/typescript), es fácil que te salpique:

// Partimos de esta función...
function printNumbers(
  numbers: number[], 
  printer: (n: number) => void) {
    numbers.forEach(printer);
}

// ...que podríamos usar de esta forma
printNumbers([1, 2, 3], n => console.log(n));

// Típica función que, tal vez, viola el SRP
function printAndKill(n: number, killKitten?: boolean): void {
    console.log(n);
    if (killKitten) {
        // Matar gatito
    }
}

// Ops. 2 animalillos menos.
printNumbers([1, 2, 3], printAndKill)

Si analizamos las asignaciones de funciones que se están haciendo, veremos que la «flexibilidad» que proporciona TypeScript quizá sea excesiva:

Primero se permite pasar como parámetro printer de la función printNumbers la función printAndKill. Eso es posible porque el segundo parámetro de printAndKill es opcional, así que se supone que es capaz de trabajar con sólo un number, que es lo que se le pasará a printer.

Hasta aquí, vamos bien.

Después la función printer, que recibe un number, nos permite asignarla al parámetro del método forEach de Array<number>, que tiene como tipo (value: number, index: number, array: number[]) => void).

Esto es así porque una función que recibe menos parámetros (printer) es asignable a una que recibe más (la callback de forEach). Total, los que le pasen extra los ignora y ya está, ¿no?

El problema es que printer no sólo recibe un parámetro number, sino que internamente almacena una referencia a printAndKill, que recibe, opcionalmente un segundo parámetro. Ops. Si unimos esto al tipado débil de javascript, cuando se invoque printAndKill se hará la coerción de index a boolean, por lo que inadvertidamente mataremos gatitos en cuanto index != 0.

Solidez en sistemas de tipos

Esto que hemos visto no es algo exclusivo del sistema de tipos de TypeScript, y hay muchos lenguajes (entre ellos todos los más populares) que tienen problemas parecidos. Por ejemplo, en C# podemos encontrar un caso similar con la covarianza de arrays, que permite hacer cosas como ésta:

Dog[] dogs = new [] { new Dog() };

// OK: los arrays con covariantes en C#
Animal[] animals = dogs;

// Error en tiempo de ejecución
animals[0] = new Cat(); 

A esta propiedad de los sistemas de tipos se le llama solidez (soundness). Se dice que un sistema de tipos es sólido (sound) si no permite considerar válidos programas que luego darán errores en tipo de ejecución. Al resto, se les llama frágiles (unsound).

Esto puede resultar un poco chocante. Se supone que una de las ventajas (¿la principal?) de los sistemas de tipado estático es que permite comprobar en tiempo de compilación la validez del programa (desde el punto de vista de compatibilidad de tipos). Si a veces da por buenos programas inválidos, ¿qué sentido tiene?

La realidad es un poco más complicada y, al igual que los sistemas de tipado dinámico o gradual tienen su utilidad, tener sistemas frágiles también tiene sus ventajas. Por una parte puede simplificar la parte de validación de tipos, mejorando el rendimiento de los compiladores. Por otra, hay escenarios en los que gracias a la falta de solidez se simplifica mucho el código a escribir.

Por ejemplo, si en el caso anterior de C# no existiera covarianza de arrays, no se podría hacer cosas como:

abstract class Animal {
  abstract void Move()
}
class Dog : Animal {...}
class Cat: Animal {...}

void MoveAll(Animal[] animals) {
  foreach (var animal in animals)
    animal.Move();
}

MoveAll(new Dog[] { new Dog(), new Dog() });
MoveAll(new Cat[] { new Cat(), new Cat() });

En ese caso se podría solucionar haciendo los arrays invariantes y haciendo que MoveAll recibiera un tipo covariante (por ejemplo IEnumerable), pero hay veces que compensa sacrificar la solidez para facilitar este tipo de patrones de uso.

Para el caso de TypeScript, en esta discusión de GitHub podéis encontrar varios argumentos interesantes sobre el tema.

2 comentarios en “TypeScript: varianza y solidez

  1. Un post muy interesante, gracias!

    No toco C# desde hace mucho, pero que tal:

    static void move(T[] xs) where T: Animal {
    foreach(T x in xs)
    x.move();
    }

    move(new Dog[] {new Dog()});
    move(new Cat[] {new Cat()});
    move(new Animal[] {new Dog(), new Cat()});

  2. Gracias, es una opción muy buena.

    La covarianza de arrays es anterior a la aparición de genéricos en C#, tal vez si se hubieran diseñado las apis desde el principio contando con ellos se la podrían haber ahorrado.

Comentarios cerrados.