Главная / Статьи

Пять ловушек API Temporal, которые тихо вновь приводят к проблемам с датами

Узнайте, как API Temporal в JavaScript всё ещё может вызывать проблемы с часовыми поясами, сериализацией, точностью и продолжительностью, если не исправить пять распространённых способов неправильного использования.

Издатель: REACTAPP.TOP9 мин чтения1977 слов

Источники: JavaScript Temporal API: 5 Critical Mistakes and How to Fix Them!

Уведомление о ежедневном напоминании, которое появляется на час раньше каждый раз при смене времени из-за летнего времени. Заявка на поддержку, зарегистрированная не в тот день календаря, потому что сервер работает по UTC, в то время как клиент находится в совершенно другом часовом поясе. Финансовый отчет, который периодически не проходит проверку на равенство в системе CI, причем нет четкого способа воспроизвести эту ошибку на локальной машине.

Ни одна из этих проблем не является ошибкой, вызванной классом Date. Это временные ошибки, и они возникают в кодовых базах, которые приняли подход Temporal именно для того, чтобы избавиться от ошибок, связанных с датами.

Начиная с Node.js 26.8.2, библиотека Temporal поставляется в составе ядра без необходимости использования экспериментальных флагов. Chrome 144 поддерживает её из коробки. Комитет TC39 перевёл этот предложение на стадию 4, что означает, что речь больше не идёт о предполагаемом будущем дополнении — это уже устоявшаяся часть языка, и команды активно используют её вместо Moment.js, date-fns и собственных алгоритмов вычислений на основе объекта Date. Именно в процессе такой миграции чаще всего возникают пять ошибок, описанных ниже.

Temporal устраняет старые проблемы класса Date — изменяемость, неоднозначную обработку данных и отсутствие чёткого разделения между календарной датой и точным моментом времени — путём внедрения более строгой и явной модели данных. Проблемы возникают тогда, когда разработчики рассматривают его лишь как «более удобную версию Date». Именно это предположение приводит к описанным здесь ошибкам, причём эти ошибки обычно более коварны, чем старые, и их труднее обнаружить.

Ошибка 1: Вызов Temporal.Now.plainDateISO() без определения, какая «сегодняшняя дата» имеется в виду

Это, вероятно, самая распространённая ошибка, главным образом потому, что код, вызывающий её, на первый взгляд кажется совершенно корректным.

// Inside an API route running on a server in us-east-1
function getTodaysDigestDate() {
  const today = Temporal.Now.plainDateISO();
  return today.toString();
}

При вызове Temporal.Now.plainDateISO() без аргументов значение «сейчас» определяется с учетом часового пояса системы — то есть того пояса, в котором работает сервер, а не того, в котором находится конечный пользователь. Представьте клиента в Токио, который открывает ваше приложение в 8 утра по местному времени: если сервер находится в режиме UTC и еще не перешел полночь, дата в результате для этого клиента будет соответствовать прошлому дню. Это та же самая ошибка, связанная с отклонением на один единицу, которая раньше возникала при использовании new Date().toDateString() — она просто снова появилась в коде, который разработчики считали защищенным от нее только потому, что в нем используется Temporal.

Лучший подход:

function getTodaysDigestDate(userTimeZone) {
  // userTimeZone comes from the user's profile or resolved request context,
  // e.g. "Asia/Tokyo" — never the server's implicit zone.
  const today = Temporal.Now.plainDateISO(userTimeZone);
  return today.toString();
}

Temporal никогда не решал вопрос «относительно какой зоны?» — он просто заставляет вас явно на него ответить, вместо того чтобы позволять ему действовать по умолчанию без комментариев. Любой вызов Temporal.Now.*, в котором не указана конкретная зона, является решением, принятым вами незаметно. Стоит просмотреть ваш код на наличие вызовов Temporal.Now.plainDateISO(), Temporal.Now.zonedDateTimeISO() и Temporal.Now.plainDateTimeISO() без указания зоны — каждый из них тайно отвечает на вопрос «сегодня, по мнению кого?» ответом «в той зоне, в которой работает сервер».

Ошибка 2: Предположение, что передача данных в формате JSON преобразует значение Temporal обратно в объект Temporal

Объекты временного хранения имеют метод toJSON(), поэтому их передача в функцию JSON.stringify() кажется безупречно работающей — и именно это кажущееся успехом поведение становится причиной возникновения следующей ошибки.

const state = { renewalDate: Temporal.PlainDate.from("2026-11-03") };
localStorage.setItem("sub", JSON.stringify(state));
// ...later, on page reload...
const restored = JSON.parse(localStorage.getItem("sub"));
const daysLeft = restored.renewalDate.since(Temporal.Now.plainDateISO()).days;

JSON.stringify на самом деле вызывает toJSON() и генерирует обычную строку в формате ISO. Однако JSON.parse не может определить, что эта строка ранее представляла собой объект Temporal.PlainDate — он просто возвращает строку, а не экземпляр класса. Выполнение приведённого выше фрагмента кода приводит к ошибке TypeError: restored.renewalDate.since is not a function; это обычно происходит в реальных условиях, после того как значение прошло через какой-либо механизм сериализации: localStorage, вызов postMessage в Web Worker, передача свойства серверному компоненту или сохранение в Redux-хранилище. При локальной разработке, когда работа ведётся в одном табе и в рамках одного цикла выполнения, эту проблему легко упустить — она проявляется только тогда, когда значение действительно сериализуется и затем снова читается.

Лучший подход:

const restored = JSON.parse(localStorage.getItem("sub"));
const renewalDate = Temporal.PlainDate.from(restored.renewalDate);
const daysLeft = renewalDate.since(Temporal.Now.plainDateISO()).days;

.from() для воссоздания объекта, и каждый раз, когда такое значение проходит через слой, преобразующий его сначала в обычный текст или JSON — будь то хранилище браузера, сообщение рабочего процесса, заголовок API или передача данных между сервером и клиентом в рамках фреймворка — вы несете ответственность за явную их перевертку обратно после этого процесса. Ничто не выполняет такую реконструкцию автоматически, и ничто не выдает предупреждения при пропуске этого шага.

Ошибка 3: Забывание о том, что ваша база данных не может обеспечить такую же точность, как Temporal

Temporal.Instant позволяет измерять время с точностью до наносекунды. Практически ни одна из систем, куда вы передаете такие значения, не способна сохранять информацию на таком уровне детализации.

const capturedAt = Temporal.Now.instant(); // nanosecond precision
await db.query(
  "INSERT INTO events (captured_at) VALUES ($1)",
  [capturedAt.toString()]
);
// later, comparing the freshly captured instant to the one read back from Postgres
const row = await db.query("SELECT captured_at FROM events WHERE id = $1", [id]);
const stored = Temporal.Instant.from(row.captured_at);
console.log(capturedAt.equals(stored)); // false — intermittently, not always

Тип столбца timestamptz в Postgres сохраняет точность только до микросекунд, а обычный объект Date в JavaScript хранит данные только с точностью до миллисекунд. При записи объекта Instant с точностью до наносекунд в любой из этих типов дополнительные цифры незаметно удаляются во время вставки. Сравнение, показанное выше, технически не неверно — оно просто сопоставляет значение с уровнем точности, который на самом деле никогда не сохранялся в хранилище. Если это остаётся незамеченным, это приводит к периодически неудачным тестам, проверкам на уникальность, при которых иногда пропускаются «дубликаты», или к аудит-логам, в которых два события, произошедших в ту же микросекунду, становятся неотличимыми без какой-либо преднамеренной цели.

Правильное использование:

const capturedAt = Temporal.Now.instant().round({ smallestUnit: "microsecond" });
await db.query(
  "INSERT INTO events (captured_at) VALUES ($1)",
  [capturedAt.toString()]
);
const row = await db.query("SELECT captured_at FROM events WHERE id = $1", [id]);
const stored = Temporal.Instant.from(row.captured_at);
console.log(capturedAt.equals(stored)); // true, consistently

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

Ошибка 4: Сохранение фиксированного момента времени для события, которое ещё не произошло

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

function scheduleReminder(zone) {
  // "Remind me at 9 AM tomorrow" — computed once, at creation time
  const fireAt = Temporal.Now.zonedDateTimeISO(zone)
    .add({ days: 1 })
    .toInstant();
  return fireAt; // stored as-is, e.g. in a job queue's "run_at" column
}

Instant обозначает единую, фиксированную точку на временной шкале UTC; после преобразования местного времени в такой объект он больше не «помнит», что это соответствовало 9 часам утра по местному времени. Если запланированная дата приходится после смены временного пояса из-за летнего или зимнего времени по сравнению с моментом создания напоминания, смещение UTC для этого часового пояса изменится, и уведомление будет отправляться в 8 или 10 часов утра по местному времени, а не в 9. В момент выполнения кода с ним всё в порядке — проблема возникает только при смене времени, и именно такой тип ошибок называют «нестабильными» и они постоянно возвращаются каждую весну и осень.

Правильное использование:

function scheduleReminder(zone) {
  // Store the wall-clock intent, not a resolved instant
  const wallTime = Temporal.Now.plainDateTimeISO(zone).add({ days: 1 });
  return { wallTime: wallTime.toString(), timeZone: zone };
}
function resolveNextFireTime({ wallTime, timeZone }) {
  // Resolve to an actual Instant only when you're about to act on it
  return Temporal.PlainDateTime.from(wallTime)
    .toZonedDateTime(timeZone)
    .toInstant();
}

Если на самом деле вы имеете в виду «9 утра по местному времени, независимо от смещения часового пояса», сохраняйте значение, соответствующее времени с точки зрения настенных часов, вместе с часовым поясом, и преобразуйте его в конкретный объект Instant лишь в самый последний момент — непосредственно перед тем, как использовать его, а не в момент планирования действия.

Ошибка 5: Использование вычислений в миллисекундах вместо Temporal.Duration

Привычки, пришедшиеся с метода Date.getTime(), не исчезают автоматически только потому, что вы перешли на новый тип.

function oneWeekLater(zonedDateTime) {
  const msPerWeek = 7 * 24 * 60 * 60 * 1000;
  const nextMs = zonedDateTime.epochMilliseconds + msPerWeek;
  return Temporal.Instant.fromEpochMilliseconds(nextMs)
    .toZonedDateTimeISO(zonedDateTime.timeZoneId);
}

Календарная неделя не всегда составляет ровно 168 часов. Когда период, который вы вычисляете, проходит через границу перевода на летнее время, добавление к значению эпохи количества миллисекунд, эквивалентного 7 * 24 часам, приводит к неверному времени по местному часовому поясу — встреча, запланированная «через неделю», смещается на час, хотя сама календарная дата остается неизменной. По сути это более тихая версия Ошибки 4, и она склонна снова возникать в любой функции, которая по-прежнему основана на прямых арифметических операциях с миллисекундами, просто потому что именно так раньше выполнялись вычисления с датами.

Правильное использование:

function oneWeekLater(zonedDateTime) {
  return zonedDateTime.add({ weeks: 1 });
}

Методы вроде .add() и .subtract(), основанные на Temporal.Duration, учитывают календарь и часовой пояс, поэтому выражение «через неделю» корректно приводит к тому же времени по циферблату в тот же день недели, включая перевод часов. Как только вы начинаете рассчитывать сырой сдвиг в миллисекундах и затем преобразовывать его обратно, вы выходите за рамки той части API Temporal, которая предназначена для корректной обработки таких операций.

Что связывает все пять ошибок

Каждая из этих проблем имеет одну и ту же коренную причину: Temporal заставляет вас явно указывать часовой пояс, точность и намерение, а каждая ошибка возникает из-за того, что подспудно используются имплицитные предположения, связанные с типом Date. Temporal никогда не предназначался как механический заменитель — это более строгая модель с именами методов, которые лишь кажутся знакомыми.

Краткий обзор:

  • Никогда не вызывайте Temporal.Now.*, не определив заранее, какой часовой пояса «текущее время» вам действительно нужно
  • Восстанавливайте значения Temporal с помощью метода .from() по обе стороны от любой границы сериализации — JSON превращает их в обычные строки, а не живые объекты, во время передачи данных
  • Округляйте значения вниз, чтобы они соответствовали тому, что фактически может сохранить ваш слой хранения перед сохранением, а не позже во время отладки неожиданно сбившихся проверок на равенство
  • Для событий, запланированных на будущее, сохраняйте точное время по часам вместе с часовым поясом, преобразуя его в объект Instant только тогда, когда собираетесь им воспользоваться
  • Для всех операций, связанных с календарными датами, предпочитайте арифметику на основе Temporal.Duration — методы .add(), .subtract() — вместо расчётов на основе миллисекунд от эпохи
  • Библиотека Temporal действительно эффективно решает проблемы, накопившиеся за десятилетие с использованием типа Date. Однако она заменяет старую хаотичность на меньшее количество более острых проблем, и теперь, когда Node 26 и Chrome 144 включают её по умолчанию, всё больше команд сталкивается с этими проблемами непосредственно в производстве в процессе миграции