Контейнерные типы: Result, Maybe, Either, Sum

Контейнерный тип — обёртка, защищающая значение через внешний интерфейс. Контейнерные типы (Record, Tuple) и алгебраические типы (Maybe, Either, Result, Sum) — два разных понятия. Не все они монады в строгом смысле.

Контейнерные vs алгебраические

Это контейнерные типы. У нас есть специальный внешний интерфейс, который позволяет защищать значение, делать его иммутабельным.

Контейнерные типы — про защиту значения. Алгебраические — про моделирование альтернатив (Sum = "одно из").

Это не монады, они не реализуют map/apply полностью. Это контейнеры для значения.

Records & Tuples — отменённый proposal

Нам долгое время обещали records, делать их иммутабельными, ставить решётку перед объектами. Но этого не будет.

Решение — реализовать Record руками:

class Record {
  static immutable(fields) { return Record.#build(fields, true); }
  static mutable(fields) { return Record.#build(fields, false); }

  static #build(fields, frozen) {
    return class {
      static create(...values()) {
        const obj = Object.create(null);
        for (let i = 0; i < fields.length; i++) {
          obj[fields[i]] = values[i];
        }
        return frozen ? Object.freeze(obj) : obj;
      }
    };
  }
}

const City = Record.immutable(['name']);
const User = Record.mutable(['id', 'name', 'city', 'email']);

const rim = City.create('Rim');
const mark = User.create(1, 'Marcus', rim, 'm@rome');

При помощи Record.immutable создаём класс с одним полем name. Mark mutable, но форма фиксирована — нельзя удалить поле.

Object.create(null) быстрее литерала

const obj = Object.create(null);

Объект без цепочки прототипного наследования. Несколько шагов поиска свойств исключены. Поэтому такие records работают даже быстрее объектного литерала.

fork и update

Record.fork(obj, { name: 'new' });   // копия с изменением
Record.update(obj, { name: 'new' }); // мутация (только mutable)

Update меняет поля у мутабельных. Fork создаёт копию и в ней меняет значение. Immutable обновляется только через fork.

Maybe — алгебраический тип

class Maybe {
  static of(value) { return new Maybe(value); }
  isNothing { return this._value == null; }
  map(fn) { return this.isNothing ? this : Maybe.of(fn(this._value)); }
  chain(fn) { return this.isNothing ? this : fn(this._value); }
  getOrElse(d) { return this.isNothing ? d : this._value; }
}

Защищает от null/undefined в цепочке.

Either — Result

class Right { constructor(v) { this._v = v; } map(f) { return new Right(f(this._v)); } chain(f) { return f(this._v); } fold(_, on) { return on(this._v); } }
class Left  { constructor(e) { this._e = e; } map { return this; }              chain { return this; }     fold(on, _) { return on(this._e); } }

const tryCatch = (fn) => {
  try { return new Right(fn); }
  catch (e) { return new Left(e.message); }
};

tryCatch( => JSON.parse(input))
  .map(obj => obj.user)
  .fold(
    err => console.error('parse failed:', err),
    val => console.log('user:', val)
  );

Left "пропускает" дальнейшие map/chain — обработка ошибок без try/catch.

Sum type — "одно из"

// псевдо-схема через discriminator
const Event =
  | { type: 'click', x: number, y: number }
  | { type: 'key',   code: string };

function handle(e) {
  switch (e.type) {
    case 'click': /* x, y известны */; break;
    case 'key':   /* code известен */; break;
  }
}

В TS это полноценный discriminated union. В JS — через ручной discriminator.

Валидация через схемы и функции

Можно сделать функции, которые будут валидировать каждое значение — isEmail, isURL. По сути валидация схемами + валидация через функции + проверка по справочнику.

Зачем

  • Защита от мутаций вложенных объектов
  • Цепочки без проверок на null
  • Errors as values вместо exceptions
  • Чёткое моделирование "одно из" вариантов
  • Документация типа через имя контейнера

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

Ресурсы