Simular interfaces en JavaScript con anotaciones JSDoc

Fecha de publicación: 2024-08-07

JavaScript no incorpora interfaces como TypeScript. Aun así, cuando una aplicación crece, conviene fijar una estructura y un comportamiento comunes para sus piezas. Una interfaz expresa un contrato: los métodos que un objeto debe ofrecer. Reduce supuestos ocultos y hace el código más fácil de mantener. En JavaScript podemos representarlo con código normal y las anotaciones JSDoc @interface y @implements.

Las reglas de Frank Martin en la película «Transporter» lo explican bien:

  1. no cambiar nunca el trato;
  2. sin nombres;
  3. no abrir nunca el paquete.

Quien quiera contratarlo debe aceptar esas condiciones. En la pareja «quien realiza el trabajo — cliente», quien realiza el trabajo define las reglas. En el código, ese papel corresponde a la interfaz.

Imaginemos tres paquetes npm: plugin presta una función de negocio —repartir— y app1, app2 la usan con datos distintos. El plugin expone drive(pack, route). Para entregar necesita dimensiones y peso del paquete, origen y destino. Sus expectativas pueden escribirse así:

/** @interface */
class Package {
  getSize() { throw new Error('Implement this method.'); }
  getWeight() { throw new Error('Implement this method.'); }
}
/** @interface */
class Route {
  getPlaceFrom() { throw new Error('Implement this method.'); }
  getPlaceTo() { throw new Error('Implement this method.'); }
}

Se podría usar solamente JSDoc, pero los IDE modernos no siempre lo interpretan de la misma manera. Por eso prefiero código JavaScript corriente marcado con @interface: el contrato queda claro tanto para las personas como para las herramientas.

La primera aplicación entrega un objeto con los métodos requeridos:

function app1() {
  const pack = {
    getSize: () => ({ length: 150, width: 50, height: 50 }),
    getWeight: () => 50,
  };
  const route = {
    getPlaceFrom: () => 'Marseille',
    getPlaceTo: () => 'Nice',
  };
  drive(pack, route);
}

La segunda utiliza datos diferentes, pero cumple el mismo contrato:

function app2() {
  const pack = {
    getSize: () => ({ length: 45, width: 30, height: 10 }),
    getWeight: () => 1,
  };
  const route = {
    getPlaceFrom: () => 'Nice',
    getPlaceTo: () => 'Grenoble',
  };
  drive(pack, route);
}

Los IDE ya pueden analizar este código, completar métodos y navegar hasta sus definiciones.

En resumen:

Las interfaces ayudan a dividir un sistema complejo en componentes manejables y a dibujar límites explícitos entre ellos. JavaScript ya tiene las piezas necesarias: acordar la forma de los objetos, documentar el acuerdo en el código y recordar el principio de Frank Martin: las reglas las establece quien ejecuta el trabajo.

Hay demostraciones en app1 y app2. Ambas usan @teqfw/di para vincular interfaces e implementaciones mediante inyección de dependencias por constructor.

Fragmentos adicionales de código fuente

}
}
class Route {
getPlaceFrom() {
} getPlaceTo() {
}
}
function drive(pack, route) {}
function app1() {
const pack = {
};
const route = {
};
drive(pack, route);
}
function app2() {
const pack = {
};
const route = {
};
drive(pack, route);
}