Имитация интерфейсов в JavaScript с аннотациями JSDoc

Дата публикации: 2024-08-07

В JavaScript нет встроенных интерфейсов, как в TypeScript. Но по мере роста приложения становится важно закреплять общую структуру и поведение его частей. Интерфейс задаёт контракт: какие методы должен предоставить объект. Это уменьшает число скрытых предположений и упрощает сопровождение. В JavaScript такой контракт удобно выразить обычным кодом и аннотациями JSDoc @interface и @implements.

Хорошая иллюстрация — правила Фрэнка Мартина из фильма «Перевозчик»:

  1. никогда не менять условия сделки;
  2. не называть имён;
  3. никогда не открывать посылку.

Клиент получает услугу, только согласившись с правилами исполнителя. В паре «исполнитель — заказчик» именно исполнитель формулирует условия. В коде эту роль играет интерфейс.

Представим три npm-пакета: plugin выполняет бизнес-функцию — доставку; app1 и app2 используют его с разными данными. Плагин экспортирует drive(pack, route). Чтобы доставить посылку, ему нужны размеры и вес, место отправки и место назначения. Ожидания можно описать так:

/** @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.'); }
}

Только аннотаций JSDoc тоже достаточно, но современные IDE не всегда одинаково хорошо их понимают. Поэтому я предпочитаю обычный JavaScript-код с аннотацией @interface. Так контракт виден человеку и доступен инструментам.

Первое приложение может передать объект с нужными методами:

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

Во втором приложении данные другие, но контракт тот же:

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

IDE уже умеют разбирать такой код: подсказывать методы и переходить к их определениям.

Итак:

Интерфейсы полезны потому, что делят сложную систему на более простые части и ясно проводят границы между ними. В JavaScript уже есть всё необходимое: договориться об объектах, выразить договорённость в коде и не забывать принцип Фрэнка Мартина — правила задаёт тот, кто выполняет работу.

Демонстрации: app1 и app2. Для связывания интерфейсов и реализаций в них используется @teqfw/di — внедрение зависимостей через конструктор.

Дополнительные фрагменты исходного кода

}
}
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);
}