Callable, Thenable, Iterable, Observable

Поведенческие контракты JavaScript: что-то Callable если можно вызвать, Thenable если есть метод then, Iterable если есть Symbol.iterator, Observable если есть subscribe. Это не классы, это duck-typed контракты, на которые опирается среда (await, for-of, spread, RxJS).

Callable

typeof fn === 'function'; // вот и весь контракт

Callable бывают синхронные и асинхронные. Если в array.filter передали callable, он отработает синхронно — там нет перехода на event loop.

Контракты callback:

  • Sync: arr.filter(item => item > 0) — данные первым аргументом
  • Async error-first: readFile(path, (err, data) => {}) — ошибка первая

В асинхронные callable передаём первым параметром ошибку, вторым — данные.

Не больше двух параметров в callback

Я не рекомендую делать аргументов больше двух — ошибка и данные. Лучше объедините данные в один объект.

Упрощает контракт и V8-оптимизацию.

null вместо undefined для ошибки

cb(null, data);     // нет ошибки — null
return value ?? undefined;  // для скаляро-подобных — undefined

Если ошибка не приходит — должен быть null, а не undefined. Функция и объект совместимы с nullable type. Для скаляро-подобных значений — undefined.

В JavaScript реально есть один скаляр — это undefined. Всё остальное — определённые боксы, даже null и boolean — боксированные типы.

Infinity и NaN вместо union-типов

// плохо: number | Error → union, лишние проверки
// хорошо: NaN при невалидном результате
function calc(x) { return x < 0 ? NaN : Math.sqrt(x); }

Используйте Infinity и NaN — они совместимы по типам, не нужны union-types. V8 плохо оптимизирует union number | error.

Proxy и callable

const callableProxy = new Proxy(fn, {
  apply(target, thisArg, args) { return target(...args); }
});

Можно сделать перехват вызова в Proxy только если это до этого была asynchronous function, функция, конструктор — что-то уже callable. Над массивом apply-trap работать не будет.

Thenable

const thenable = {
  then(onFulfilled, onRejected) {
    onFulfilled(42);
    return this; // может возвращать себя
  }
};
await thenable; // работает

Thenable — объект с методом then. У него может не быть других методов, catch не обязателен.

Promise строже thenable

Promise — более строгий контракт. Он наследует thenable, потому что thenable используется для await.

Свойство thenable Promise
Метод then да да
catch опционально да
Иммутабельность состояния нет да
Цепочка с новыми Promise нет да

await требует только then

Если у объекта есть метод then, от чего бы он ни наследовал — это может быть какой-то MySuperObject — оно вызовется. await вызовет этот метод.

Thenable быстрее Promise

Создание Promise в V8 — очень затратная операция. Если можно обойти, лучше обойти. Thenable создаётся на уровне самого V8, а за Promise во многом отвечает host-среда. Это работает медленнее, намного медленнее.

function createFastResolved(value) {
  return { then(resolve) { resolve(value); } };
}
// Вместо: return Promise.resolve(value);

Iterable

const iterable = {
  [Symbol.iterator] {
    let i = 0;
    return {
      next {
        return i < 3 ? { value: i++, done: false } : { value: undefined, done: true };
      }
    };
  }
};

for (const x of iterable) console.log(x); // 0, 1, 2
[...iterable]; // [0, 1, 2]

Контракт: [Symbol.iterator] возвращает iterator с методом next. for-of, spread, destructuring, Array.from — всё работает через iterable.

Array-like

const arrLike = { 0: 'a', 1: 'b', length: 2 };
Array.from(arrLike);  // ['a', 'b']

Не iterable, но Array.from понимает. Также arguments в обычной функции, NodeList в DOM.

Observable

const observable = {
  subscribe(observer) {
    setInterval( => observer.next(Math.random), 1000);
    return { unsubscribe {} };
  }
};

Контракт: subscribe(observer) → subscription. observer имеет next/error/complete. Не стандартизировано в JS, но фактически принято в RxJS.

Stream

Node.js streams — это Readable/Writable/Duplex/Transform. Реализуют свой контракт через emit('data'), но также являются async iterable начиная с Node 10.

Связанные темы

Ресурсы