П’ять пасток API часу, які тихо знову вводять проблеми з датами
Дізнайтеся, чому API Temporal у JavaScript все ще може спричиняти проблеми з часовими поясами, серіалізацією, точністю та тривалістю, якщо не виправити п’ять поширених способів неправильного використання.
Джерела: 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
Об’єкти типу 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, передача значення як властивості до Server Component чи збереження у Redux store. Під час локальної розробки, коли все відбувається у тій самій вкладці та під час одного етапу виконання, цю проблему легко проігнорувати — вона з’являється лише тоді, коли значення справді серіалізується та згодом знову читається.
Кращий підхід:
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:00 ранку за місцевим часом, незалежно від значення зсуву“, зберігайте значення годинника разом із часовою зоною та перетворюйте його на конкретний об’єкт 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 включають його за замовчуванням, все більше команд стикається з цими проблемами безпосередньо в продакшені під час міграції