Notas de Modri

Haciendo ilegales los estados no representables en tiempo de ejecución (en TypeScript)

Sun 13 June 2021 / typescript desarrollo tipado

TL;DR

Es posible tener estados no deseados en un programa válido hecho en Typescript en tiempo de ejecución, ya que Typescript sólo chequea validez del programa en tiempo de compilación.

NOTA

A la compilación de código fuente a código fuente, como es el caso de Typescript a Javascript, se la llama transpilación.

Explicación

El compilador de Typescript, tsc, transforma el código Typescript en código Javascript. tsc chequea estáticamente la validez del programa al momento de traducirlo a código Javascript, en tiempo de ejecución tenemos código Javascript ejecutando en un intérprete Javascript.

Es decir, es posible escribir código Typescript válido que al ejecutarse produzca errores no esperados. Algunas posibles causas:

  1. Falta de entendimiento en el unsoundness de Typescript
  2. Uso incorrecto de Type Guards
  3. Uso de bibliotecas de terceros que no validan correctamente tipos en tiempo de ejecución

Causas

1. Unsoundess

Por cuestiones de practicidad Typescript tiene un sistema de tipos unsound1, es decir, el sistema de tipos permite que ciertas operaciones entre tipos sean válidas, aunque podrían no serlo.

Es decir, Typescript permite:

interface Perro {
    nombre: string
}

interface InterfazDePersona {
    nombre: string,
    profesion: string
}

const marley: InterfazDePersona = {
    nombre: 'Marley',
    profesion: 'presentador'
};

const marleyElPerro: Perro = marley;

Esto puede resultar práctico cuando se obtienen objetos cuyas propiedades se van modificando de manera incremental (es decir, se agregan propiedades) y no queremos que el programa falle.

El problema con ello es que puede dar lugar a asignaciones válidas Typescript, pero inválidas en nuestro modelo computacional.

Cuanto más descriptivos nuestros modelos, menos probable que esto suceda. Por descriptivos entendemos que nuestras clases tengan métodos y propiedades, minimizando su opcionalidad.

NOTA

Typescript es un lenguaje estáticamente tipado, pero, ¿es fuertemente tipado? Para ser un lenguaje fuertemente tipado no debería permitir coherciones de tipo.

En general por practicidad los lenguajes de programación no son fuertemente tipados estrictamente hablando. Por ejemplo, permiten operar entre números enteros y números de punto flotante.

Typescript restringue ciertas operaciones, por ejemplo algunos usos del operador ==, permite otras como + entre string y number o la sentencia if (unaVariable) donde unaVariable no necesariamente tiene que ser del tipo boolean.

Nuevamente, estas restricciones sólo funcionan al momento de compilar, en tiempo de ejecución el código es débilmente tipado.

2. Type Guards

Los Type Guards2 son funciones o expresiones booleanas que nos permiten inferir el tipo del objeto fácilmente en nuestro código y que Typescript nos deje acceder a métodos y propiedades del objeto.

El problema es que queda del lado del desarrollador cómo inferir el tipo y esto es propenso a errores. Por ejemplo, si utilizamos una propiedad para inferir una clase:

export interface InterfazDeAnimal {
    nombre: string;
}

// Clase que implementa la interfaz
export class Animal implements InterfazDeAnimal {

    constructor(unNombre: string) {
        this.nombre = unNombre;
    }

    seLlama(posibleNombre: string) : boolean{
        return this.nombre === posibleNombre;
    }
}

// Type Guard
// Asume que es de la clase cuando solamente cumple su interfaz
function EsUnAnimal__UsandoPropiedad(
    posibleAnimal: unknown
    ): posibleAnimal is Animal {
    return typeof posibleAnimal === "object" &&
           posibleAnimal != null &&
           'nombre' in posibleAnimal;
}

const posibleAnimal: unknown = { nombre: 'Toto' };
if (EsUnAnimal__UsandoPropiedad(posibleAnimal)) {

    // No falla porque el objeto tiene la propiedad nombre
    assert.that(posibleAnimal.nombre).isEqualTo('Toto');

    // Lanza un TypeError porque 
    // el objeto no tiene el método seLlama
    assert.that(posibleAnimal.seLlama('Toto')).isTrue();

}

El problema aquí es una mala definición del type guard: la clase Animal extiende la interfaz InterfazDeAnimal, pero no son lo mismo. Typescript no avisará de este error, una forma de detectarlo podría ser mediante tests o siendo cuidadosos al crear el type guard.

Siendo Animal una clase se puede verificar si es del tipo usando instanceof.

De todas formas, el uso de instanceof se puede circundar:

Ejemplo del primero:

function EsUnAnimal__UsandoTipoDeInstancia(
    possibleAnimal: unknown
    ): possibleAnimal is Animal  {
    return typeof possibleAnimal === "object" &&
            possibleAnimal != null &&
            possibleAnimal instanceof Animal;
}

const possibleAnimal: unknown = {};
// Si bien su uso es desaconsejado, se puede usar.
Object.setPrototypeOf(possibleAnimal, Animal.prototype);

if (EsUnAnimal__UsandoTipoDeInstancia(possibleAnimal)) {

    // Se puede acceder al método, pero da falso
    // porque la propiedad nombre es undefined
    assert.that(possibleAnimal.seLlama('Toto')).isEqualTo(true);
    assert.that(possibleAnimal.nombre === 'undefined').isTrue();
}

Parece bastante naive hacer la asignación del prototipo, pero ¿qué hay de lo segundo? Eso tiene el siguiente aspecto:

// objetoConPropiedades se creó antes en la ejecución del código
const nuevaInstancia: LaClase = new LaClase();
Object.assign(nuevaInstancia, objetoConPropiedades);

Es muy posible que si LaClase tiene propiedades, el compilador de Typescript muestre algún error o advertencia sobre el constructor por defecto sin parámetros.

¿En todos los casos Typescript muestra la advertencia o error sobre el uso de un constructor por defecto? Lamentablemente, no en los casos en que el constructor se llama:

Lo segundo nos lleva a nuestro tercer punto: bibliotecas de terceros.

3. Bibliotecas de terceros

Las bibliotecas de terceros es código que no está bajo nuestro dominio y que podría hacer alguna de las cosas mencionadas o todas ellas a la vez. Como es código distribuido es código Javascript, es decir, si la biblioteca está hecha en Typescript al distribuirla se compila a Javascript.

Es por eso que deberíamos tomar con cuidado las instancias provenientes de las bibliotecas de terceros.

Un ejemplo es builder-pattern. La biblioteca permite implementar facilmente el patrón de diseño constructor (builder en inglés) y es una derivación de una solución en una pregunta de StackOverflow .

Por ejemplo, el siguiente código construye una instancia inválida de la clase Animal definida arriba en la versión v1.24 de la biblioteca.

// Valido en v1.24
const possibleAnimal: Animal = Builder(Animal, {}).build();
assert.that(possibleAnimal.seLlama('Toto')).isEqualTo(false);
assert.that(possibleAnimal.nombre).isUndefined();

En la v1.30 el autor actualizó la biblioteca usando nuevas funcionalidades Typescript para hacer más seguro el código en cuanto al tipado. Pero en las definiciones de las propiedades de la clase de ejemplo usa el modificador de propiedad ! (aserción de asignación definitiva). De esta manera, evita que la construcción de la clase de error.

El ! al lado del nombre de la propiedad le indica a Typescript que es segura la construcción de la clase sin inicializar todas sus propiedades, es decir, sin pasarlas como parámetros al constructor.

// Valido en v1.30
class InfoDeUsuario {
    id!: number;
    nombreUsuario!: string;
    email!: string;

    seLlama(posibleNombre: string) : boolean{
        return this.nombreUsuario === posibleNombre;
    }

}
const possibleUsuario: InfoDeUsuario = Builder(InfoDeUsuario).build();
assert.that(possibleUsuario.seLlama('Toto')).isEqualTo(false);
assert.that(possibleUsuario.nombreUsuario).isUndefined();

Conclusiones

Por diseño, Typescript puede detectar ciertos errores relacionados con el sistema de tipos, pero no todos. Es unsound, estáticamente tipado y sólo chequea validez en tiempo de compilación. Entender qué errores no detecta permite crear código más robusto y menos propenso a errores.

Que no pueda detectar ciertos errores no quita que su uso puede ser provechoso para ciertos equipos y proyectos, especialmente para crear aplicaciones multiplataforma y/o compatibles sobre múltiples navegadores.

Como todo lenguaje de programación, Typescript es una herramienta más y uno tiene que sopesar pros y contras al momento de elegirla.

El código de ejemplo usado puede encontrarse aquí.

Videos y lecturas recomendas

Modri

A cerca de Modri

Geek. Coder. Google-Fu practicioner. Tech Lead in progress. Opinions are my own.

Comments