Библиотека 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));
}
}
На практике чаще всего создаются типы, отражающие бизнес-объекты: рабочие дни, интервалы смен, дедлайны, окна доступности.
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);
}
}
Интервал становится базовым кирпичом для моделирования календарных сущностей.
Хотя 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 и хранилищах.
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)
);
}
}
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);
}
Фабрики позволяют централизовать:
В 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.
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);
}
}
Каждый тип рассматривается как значение, а не сущность. Это означает:
equals(other) {
return this.toISO() === other.toISO();
}
Luxon используется как внутренний движок, скрытый за доменной моделью.
Сначала проектируется предметная модель:
И только затем выбираются методы Luxon для реализации.
Создание собственных типов поверх Luxon требует строгого контроля:
Interval логики)Главный принцип остаётся неизменным: Luxon должен оставаться источником истины для всех операций времени, а доменные типы — лишь слоем семантики поверх него.