Имитация интерфейсов в JavaScript с аннотациями JSDoc
Дата публикации: 2024-08-07В JavaScript нет встроенных интерфейсов, как в TypeScript. Но по мере роста приложения становится важно закреплять общую структуру и поведение его частей. Интерфейс задаёт контракт: какие методы должен предоставить объект. Это уменьшает число скрытых предположений и упрощает сопровождение. В JavaScript такой контракт удобно выразить обычным кодом и аннотациями JSDoc @interface и @implements.
Хорошая иллюстрация — правила Фрэнка Мартина из фильма «Перевозчик»:
- никогда не менять условия сделки;
- не называть имён;
- никогда не открывать посылку.
Клиент получает услугу, только согласившись с правилами исполнителя. В паре «исполнитель — заказчик» именно исполнитель формулирует условия. В коде эту роль играет интерфейс.
Представим три 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 уже умеют разбирать такой код: подсказывать методы и переходить к их определениям.
Итак:
- вызывающая сторона формулирует контракт;
- реализация обязана ему соответствовать;
- JSDoc помогает 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); }