Creación de clases base reutilizables en TypeScript con un ejemplo de la vida real | Programar Plus

¡Hola, CSS-Tricksters! Bryan Hughes tuvo la amabilidad de tomar un concepto de una publicación existente que publicó sobre la conversión a TypeScript y avanzar un par de pasos más en esta publicación para desarrollar la creación de clases base reutilizables. Si bien esta publicación no requiere leer la otra, ciertamente vale la pena revisarla porque cubre la difícil tarea de reescribir una base de código y escribir pruebas unitarias para ayudar en el proceso.

Johnny-Five es una biblioteca de robótica y de IoT para Node.js. Johnny-Five facilita la interacción con el hardware al adoptar un enfoque similar al que jQuery llevó a la web hace una década: normaliza las diferencias entre varias plataformas de hardware. También como jQuery, Johnny-Five proporciona abstracciones de nivel superior que facilitan el trabajo con la plataforma.

Johnny-Five admite una amplia gama de plataformas a través de IO Plugins, desde la familia Arduino hasta Tessel, Raspberry Pi y muchas más. Cada complemento IO implementa una interfaz estandarizada para interactuar con periféricos de hardware de nivel inferior. Raspi IO, que creé por primera vez hace cinco años, es el complemento IO que implementa el soporte para Raspberry Pi.

Siempre que haya varias implementaciones que se ajusten a una cosa, existe la posibilidad de compartir código. Los complementos de IO no son una excepción, sin embargo, no hemos compartido ningún código entre los complementos de IO hasta la fecha. Recientemente decidimos, como grupo, que queríamos cambiar esto. El momento fue fortuito ya que estaba planeando reescribir Raspi IO en TypeScript de todos modos, así que acepté asumir esta tarea.

Los objetivos de una clase base

Para aquellos que no están familiarizados con el uso de la herencia basada en clases para permitir el uso compartido de código, hagamos un repaso rápido de lo que quiero decir cuando digo “crear una clase base para reutilizar el código”.

Una clase base proporciona estructura y código común que otras clases pueden extender. Echemos un vistazo a una clase base de ejemplo de TypeScript simplificada que ampliaremos más adelante:

abstract class Automobile {
  private _speed: number = 0;
  private _direction: number = 0;
  public drive(speed: number): void {
    this._speed = speed;
  }
  
  public turn(direction: number): void {
    if (direction > 90 || direction < -90) {
      throw new Error(`Invalid direction "${direction}"`);
    }
    this._direction = direction;
  }
  
  public abstract getNumDoors(): number;
}

Aquí, hemos creado una clase base que llamamos Automobile. Esto proporciona una funcionalidad básica compartida entre todos los tipos de automóviles, ya sea un automóvil, una camioneta, etc. Observe cómo esta clase se marca como abstracta y cómo hay un método abstracto llamado getNumDoors. Este método depende del tipo específico de automóvil. Al definirlo como una clase abstracta, estamos diciendo que necesitamos otra clase para extender esta clase e implementar este método. Podemos hacerlo con este código:

class Sedan extends Automobile {
  public getNumDoors(): number {
    return 4;
  }
}

const myCar = new Sedan();
myCar.getNumDoors(); // prints 4
myCar.drive(35);     // sets _speed to 35
myCar.turn(20);      // sets _direction to 20

Estamos ampliando el Automobile clase para crear una clase sedán de cuatro puertas. Ahora podemos llamar a todos los métodos en ambas clases, como si fuera una sola clase.

Para este proyecto, crearemos una clase base llamada AbstractIO que cualquier autor de un complemento IO puede ampliar para implementar un complemento IO específico de la plataforma.

Creando la clase base Abstract IO

Abstract IO es la clase base que creé para implementar la especificación del complemento IO para que la usen todos los autores del complemento IO, y está disponible hoy en npm.

Cada autor tiene su propio estilo único, algunos prefieren TypeScript y otros prefieren JavaScript vanilla. Esto significa que, ante todo, esta clase base debe ajustarse a la intersección de las mejores prácticas de TypeScript y las mejores prácticas de JavaScript. Sin embargo, encontrar la intersección de las mejores prácticas no siempre es obvio. Para ilustrar, consideremos dos partes de la especificación del complemento IO: el MODES propiedad y la digitalWrite método, los cuales son necesarios en IO Plugins.

El MODES La propiedad es un objeto, y cada clave tiene un nombre legible por humanos para un tipo de modo dado, como INPUTy el valor es la constante numérica asociada con el tipo de modo, como 0. Estos valores numéricos aparecen en otros lugares de la API, como el método pinMode. En TypeScript, querríamos usar un enum para representar estos valores, pero JavaScript no lo admite. En JavaScript, la mejor práctica es definir una serie de constantes y ponerlas en un “contenedor” de objetos.

¿Cómo resolvemos esta aparente división entre las mejores prácticas? ¡Crea uno usando el otro! Empezamos por definir un enum en TypeScript y defina cada modo con un inicializador explícito para cada valor:

export enum Mode {
 INPUT = 0,
 OUTPUT = 1,
 ANALOG = 2,
 PWM = 3,
 SERVO = 4,
 STEPPER = 5,
 UNKNOWN = 99
}

Cada valor se elige cuidadosamente para mapear explícitamente a la MODES propiedad según lo definido por la especificación, que se implementa como parte de la clase Abstract IO con el siguiente código:

public get MODES() {
 return {
   INPUT: Mode.INPUT,
   OUTPUT: Mode.OUTPUT,
   ANALOG: Mode.ANALOG,
   PWM: Mode.PWM,
   SERVO: Mode.SERVO,
   UNKNOWN: Mode.UNKNOWN
 }
}

Con esto, tenemos buenas enumeraciones cuando estamos en TypeScript-land y podemos ignorar los valores numéricos por completo. Cuando estamos en la tierra pura de JavaScript, todavía tenemos la MODES propiedad que podemos pasar de la manera típica de JavaScript para que no tengamos que usar valores numéricos directamente.

Pero, ¿qué pasa con los métodos en la clase base que las clases derivadas deben anular? En el mundo de TypeScript, usaríamos una clase abstracta, como hicimos en el ejemplo anterior. ¡Frio! Pero, ¿qué pasa con el lado de JavaScript? Los métodos abstractos se compilan y ni siquiera existen en el JavaScript de salida, por lo que no tenemos nada para garantizar que los métodos se anulen allí. ¡Otra contradicción!

Desafortunadamente, no pude encontrar una manera de respetar ambos lenguajes, así que decidí usar el enfoque de JavaScript: crear un método en la clase base que arroje una excepción:

public digitalWrite(pin: string | number, value: number): void {
 throw new Error(`digitalWrite is not supported by ${this.name}`);
}

No es ideal porque el compilador de TypeScript no nos advierte si no anulamos el método base abstracto. Sin embargo, funciona en TypeScript (todavía obtenemos la excepción en tiempo de ejecución), además de ser una de las mejores prácticas para JavaScript.

Lecciones aprendidas

Mientras trabajaba en la mejor manera de diseñar tanto para TypeScript como para JavaScript vanilla, determiné que la mejor manera de resolver las discrepancias es:

  1. Utilice una práctica recomendada común compartida por cada idioma
  2. Si eso no funciona, intente utilizar la mejor práctica de TypeScript “internamente” y mapearla a una mejor práctica de JavaScript vanilla separada “externamente”, como hicimos para el MODES propiedad.
  3. Y si ese enfoque no funciona, utilice de forma predeterminada las mejores prácticas de JavaScript vanilla, como hicimos para el digitalWrite e ignore las mejores prácticas de TypeScript.
  4. Estos pasos funcionaron bien para crear Abstract IO, pero son simplemente lo que descubrí. Si tienes alguna idea mejor, ¡me encantaría escucharla en los comentarios!

    Descubrí que las clases abstractas no son útiles en proyectos de JavaScript vanilla ya que JavaScript no tiene tal concepto. Esto significa que son un anti-patrón aquí, aunque las clases abstractas son una mejor práctica de TypeScript. TypeScript tampoco proporciona mecanismos para garantizar la seguridad de tipos al anular métodos, como el override palabra clave en C #. La lección aquí es que debe pretender que las clases abstractas no existen cuando se dirigen también a los usuarios de JavaScript vanilla.

    También aprendí que es realmente importante tener una única fuente de verdad para las interfaces compartidas entre módulos. TypeScript utiliza el tipo de pato como patrón principal del sistema de tipos y es muy útil dentro de cualquier proyecto de TypeScript. Sin embargo, sincronizar tipos a través de módulos separados usando la escritura pato resultó ser más un dolor de cabeza de mantenimiento que exportar una interfaz de un módulo e importarla a otro. Tuve que seguir publicando pequeños cambios en los módulos hasta que los alineé en sus tipos, lo que provocó una pérdida excesiva de números de versión.

    La última lección que aprendí no es nueva: obtenga comentarios de los consumidores de la biblioteca durante todo el proceso de diseño. Sabiendo esto al entrar, configuré dos rondas principales de retroalimentación. La primera ronda fue en persona en la cumbre de colaboradores de Johnny-Five, donde intercambiamos ideas como grupo. La segunda ronda fue una solicitud de extracción que abrí en mi contra, a pesar de ser el único colaborador en Abstract IO con permiso para fusionar solicitudes de extracción. La solicitud de extracción resultó invaluable mientras analizamos los detalles como grupo. El código al comienzo del PR se parecía poco al código cuando se fusionó, ¡lo cual es bueno!

    Terminando

    Producir clases base destinadas a ser consumidas tanto por usuarios de TypeScript como de JavaScript vanilla es un proceso en su mayoría sencillo, pero existen algunos inconvenientes. Dado que TypeScript es un superconjunto de JavaScript, los dos comparten en su mayoría las mismas mejores prácticas. Cuando surgen diferencias en las mejores prácticas, se requiere un poco de creatividad para encontrar el mejor compromiso.

    Me las arreglé para lograr mis objetivos con Abstract IO e implementé una clase base que sirve bien a los autores de complementos IO de TypeScript y Vanilla JavaScript. ¡Resulta que (en su mayoría) es posible tener tu pastel y comértelo también!

(Visited 4 times, 1 visits today)