Создание собственных типов

Библиотека Luxon предоставляет три базовых строительных блока для работы со временем: DateTime, Duration и Interval. Они покрывают большинство прикладных сценариев, однако в реальных проектах часто возникает необходимость создавать доменно-специфичные типы, которые инкапсулируют бизнес-логику поверх этих примитивов.

Создание собственных типов в контексте Luxon не означает расширение внутренних классов библиотеки. Luxon не проектировался для наследования и переопределения поведения. Вместо этого используется подход композиции: собственные структуры данных оборачивают экземпляры Luxon и добавляют предметную семантику.


Базовые принципы построения собственных типов

Основная идея заключается в том, что Luxon остаётся низкоуровневым слоем работы с датой и временем, а доменная модель строится поверх него.

Ключевые принципы:

Иммутабельность как основа Все объекты Luxon неизменяемы. Любая операция возвращает новый экземпляр. Собственные типы должны сохранять это свойство, не добавляя скрытых мутаций.

Композиция вместо наследования Вместо попыток расширять DateTime, создаётся класс-обёртка:

import { DateTime } from "luxon";

class WorkDate {
  constructor(dateTime) {
    this._dt = dateTime;
  }

  static fromISO(iso) {
    return new WorkDate(DateTime.fromISO(iso));
  }

  toISO() {
    return this._dt.toISO();
  }
}

Такой подход сохраняет совместимость с Luxon и предотвращает зависимость от внутренних деталей реализации.

Явное управление зонами Luxon строго разделяет локальное время и время в конкретной зоне. Собственные типы должны фиксировать или явно управлять zone:

class ZonedMoment {
  constructor(dt) {
    this._dt = dt;
  }

  static now(tz) {
    return new ZonedMoment(DateTime.now().setZone(tz));
  }
}

Оборачивание DateTime в предметные сущности

На практике чаще всего создаются типы, отражающие бизнес-объекты: рабочие дни, интервалы смен, дедлайны, окна доступности.

Тип «Рабочий день»

import { DateTime } from "luxon";

class WorkDay {
  constructor(dateTime) {
    if (!dateTime.isValid) {
      throw new Error("Invalid DateTime");
    }
    this._dt = dateTime.startOf("day");
  }

  static today(zone = "local") {
    return new WorkDay(DateTime.now().setZone(zone));
  }

  addBusinessDays(days) {
    let dt = this._dt;
    let remaining = days;

    while (remaining > 0) {
      dt = dt.plus({ days: 1 });
      if (dt.weekday <= 5) {
        remaining--;
      }
    }

    return new WorkDay(dt);
  }

  toISODate() {
    return this._dt.toISODate();
  }
}

Такой тип скрывает детали календарной логики и позволяет работать на уровне предметной области.


Интервалы как основа сложных типов

Luxon предоставляет Interval, который естественным образом подходит для построения более сложных структур.

Тип «Слот расписания»

import { DateTime, Interval } from "luxon";

class ScheduleSlot {
  constructor(start, end) {
    const interval = Interval.fromDateTimes(start, end);

    if (!interval.isValid) {
      throw new Error("Invalid interval");
    }

    this._interval = interval;
  }

  static fromISO(startISO, endISO) {
    return new ScheduleSlot(
      DateTime.fromISO(startISO),
      DateTime.fromISO(endISO)
    );
  }

  duration() {
    return this._interval.toDuration();
  }

  overlaps(other) {
    return this._interval.overlaps(other._interval);
  }

  shift({ minutes = 0, hours = 0 }) {
    const shifted = this._interval.mapEndpoints(dt =>
      dt.plus({ minutes, hours })
    );

    return new ScheduleSlot(shifted.start, shifted.end);
  }
}

Интервал становится базовым кирпичом для моделирования календарных сущностей.


Использование Duration в пользовательских единицах

Хотя Luxon поддерживает стандартные единицы времени, часто требуется вводить доменные единицы: «рабочая смена», «учебная пара», «тайм-слот обработки».

Тип «Рабочая смена»

import { Duration } from "luxon";

class WorkShift {
  constructor(duration) {
    this._duration = duration;
  }

  static standard() {
    return new WorkShift(Duration.fromObject({ hours: 8 }));
  }

  static fromHours(hours) {
    return new WorkShift(Duration.fromObject({ hours }));
  }

  add(otherShift) {
    return new WorkShift(this._duration.plus(other._duration));
  }

  toMinutes() {
    return this._duration.as("minutes");
  }
}

Создание таких типов позволяет избежать «голых чисел» и делает код самодокументируемым.


Сериализация собственных типов

Любой доменный тип поверх Luxon должен уметь корректно сериализоваться, поскольку DateTime и Duration активно используются в API и хранилищах.

DateTime-обёртки

Luxon уже предоставляет стандартизированные методы:

  • toISO()
  • toJSON()
  • toObject()

Обёртки должны делегировать:

class WorkDate {
  constructor(dt) {
    this._dt = dt;
  }

  toJSON() {
    return {
      iso: this._dt.toISODate(),
      zone: this._dt.zoneName
    };
  }

  static fromJSON(obj) {
    return new WorkDate(
      DateTime.fromISO(obj.iso).setZone(obj.zone)
    );
  }
}

Interval-объекты

class ScheduleSlot {
  toJSON() {
    return {
      start: this._interval.start.toISO(),
      end: this._interval.end.toISO()
    };
  }

  static fromJSON(obj) {
    return new ScheduleSlot(
      DateTime.fromISO(obj.start),
      DateTime.fromISO(obj.end)
    );
  }
}

Фабрики как основной способ создания типов

Прямое использование new часто заменяется фабриками для контроля валидности и инвариантов.

function createDeadline(isoString, zone = "local") {
  const dt = DateTime.fromISO(isoString, { zone });

  if (dt < DateTime.now()) {
    throw new Error("Deadline cannot be in the past");
  }

  return new WorkDate(dt);
}

Фабрики позволяют централизовать:

  • валидацию
  • нормализацию зоны
  • приведение форматов
  • обработку ошибок

Типизация поверх Luxon в TypeScript

В TypeScript подход усиливается за счёт брендированных типов, предотвращающих смешивание разных доменных сущностей.

type Branded<T, B> = T & { __brand: B };

type WorkDate = Branded<DateTime, "WorkDate">;
type Deadline = Branded<DateTime, "Deadline">;

Функции создания:

function createWorkDate(dt: DateTime): WorkDate {
  return dt.startOf("day") as WorkDate;
}

Это предотвращает случайное смешивание разных временных сущностей, даже если они основаны на одном и том же DateTime.


Композиция нескольких Luxon-типа в одном доменном объекте

Сложные модели часто объединяют несколько примитивов Luxon.

Тип «Окно доступности»

class AvailabilityWindow {
  constructor(start, end, duration) {
    this._slot = new ScheduleSlot(start, end);
    this._duration = duration;
  }

  canFit(duration) {
    return this._slot.duration().as("minutes") >= duration.toMinutes();
  }
}

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


Работа с часовыми поясами в собственных типах

Ошибки в работе с зонами — одна из наиболее частых проблем при расширении Luxon.

Практика построения типов:

  • фиксировать зону внутри объекта
  • не смешивать зоны внутри одного типа
  • явно преобразовывать при входе и выходе
class FixedZoneDate {
  constructor(dt, zone) {
    this._dt = dt.setZone(zone);
    this._zone = zone;
  }

  toUTC() {
    return this._dt.toUTC();
  }

  toZone(zone) {
    return new FixedZoneDate(this._dt.setZone(zone), zone);
  }
}

Паттерны безопасного расширения Luxon

1. Value Object

Каждый тип рассматривается как значение, а не сущность. Это означает:

  • сравнение по значению, а не по ссылке
  • отсутствие идентичности
  • полная иммутабельность
equals(other) {
  return this.toISO() === other.toISO();
}

2. Facade над Luxon

Luxon используется как внутренний движок, скрытый за доменной моделью.

3. Domain-first подход

Сначала проектируется предметная модель:

  • Slot
  • Deadline
  • WorkDay

И только затем выбираются методы Luxon для реализации.


Ограничения подхода

Создание собственных типов поверх Luxon требует строгого контроля:

  • потеря прозрачности API Luxon при чрезмерном оборачивании
  • необходимость ручной синхронизации сериализации
  • риск дублирования функциональности (например, повторение Interval логики)

Главный принцип остаётся неизменным: Luxon должен оставаться источником истины для всех операций времени, а доменные типы — лишь слоем семантики поверх него.