# Temporal у Node.js 26: нарешті нормальна робота з датами без болю Date

> Node.js 26 увімкнув Temporal за замовчуванням. Розбираємо, чому Date небезпечний у бізнес-логіці, різницю Instant / PlainDate / ZonedDateTime і реальний кейс: зустріч Валенсія–Лондон через перехід на зимовий час.

- Автор: Юра Скиба (https://cookiesoftware.io)
- Опубліковано: 2026-10-01
- Категорія: Backend і System Design
- Canonical: https://cookiesoftware.io/blog/nodejs-temporal-api

---

**Коротка відповідь.** Node.js 26 увімкнув Temporal за замовчуванням – без флагів. Головна зміна ментальна, а не синтаксична: Temporal розділяє типами те, що `Date` тридцять років змішував в одному об'єкті. **Instant** – момент часу (нічого не знає про календарі), **PlainDate** – дата без часу й зони («день народження»), **ZonedDateTime** – час на стіні в конкретному місці з усіма наслідками DST. Обираєш неправильний тип – помилку видно в коді, а не в проді о 03:00 у неділю переведення годинників. Нижче – коли який тип, кейс Валенсія–Лондон через жовтневий DST і план міграції без big bang.

## Чому Date небезпечний у бізнес-логіці

`Date` – це один тип на три різні поняття, і кожне змішування – майбутній баг:

```js
// Що це: момент часу? день? локальний час?
const d = new Date('2026-10-25');
// У Node це опівніч UTC. У локальній зоні Валенсії – ще 24.10, 23:00... ні,
// стоп, у жовтні +2... вже заплутався? Саме так виглядає half-багів із датами.
```

Додай сюди мутабельність (`setMonth` змінює об'єкт на місці), місяці з нуля, мовчазні переповнення (`new Date(2026, 1, 30)` – це 2 березня) – і стає зрозуміло, чому «робота з датами» – стабільний топ продакшн-інцидентів.

Temporal, який у Node.js 26 [увімкнено за замовчуванням](https://nodejs.org/en/blog/release/v26.0.0) (Node 26 стає LTS у жовтні 2026), вирішує це не «зручнішими методами», а системою типів.

## Instant vs PlainDate vs ZonedDateTime

Три типи – три різні запитання:

| Тип | Відповідає на | Приклади |
|---|---|---|
| `Temporal.Instant` | «Коли це сталось у фізичному часі?» | created_at у базі, лог, підпис токена |
| `Temporal.PlainDate` | «Який це день у календарі?» | день народження, дедлайн «до 15-го», дата рахунку |
| `Temporal.ZonedDateTime` | «Котра година на стіні в цьому місці?» | зустріч о 10:00 у Валенсії, відкриття магазину |

```js
// Момент: нічого не знає про зони й календарі
const paidAt = Temporal.Now.instant();

// Календарна дата: нічого не знає про години і зони
const invoiceDate = Temporal.PlainDate.from('2026-10-01');

// Час на стіні + зона: знає про DST усе
const meeting = Temporal.ZonedDateTime.from(
  '2026-10-22T10:00:00[Europe/Madrid]'
);
```

Є й допоміжні: `PlainTime` («щодня о 08:30»), `PlainDateTime` (дата+час, зона ще невідома), `Duration` (інтервали), `PlainYearMonth` (білінговий період). Усі – імутабельні: будь-яка операція повертає новий об'єкт.

Правило вибору просте: **якщо ти зберігаєш «коли сталося» – Instant; якщо «на який день домовились» – PlainDate; якщо «котра година у конкретному місті» – ZonedDateTime.** Більшість болючих багів – це Instant там, де мав бути ZonedDateTime, і навпаки.

## Кейс: зустріч Валенсія–Лондон через перехід на зимовий час

Менторська класика: щотижнева зустріч «четвер, 10:00 за Валенсією» з учасником у Лондоні. У ніч на 25 жовтня 2026 Європа переводить годинники (CEST +02:00 → CET +01:00).

Типовий баг старого світу: зустріч зберегли як UTC-момент («10:00 у Валенсії = 08:00 UTC») і додають по сім днів. Після переходу 08:00 UTC – це вже **09:00** за Валенсією: зустріч «з'їхала» на годину для всіх, хто живе за стінним часом.

Temporal робить правильну модель природною: повторювана подія – це **стінний час + зона**, а не момент:

```js
// Правило зустрічі: четвер, 10:00, час Валенсії
const beforeShift = Temporal.ZonedDateTime.from(
  '2026-10-22T10:00:00[Europe/Madrid]'
);

// Наступний тиждень – ЧЕРЕЗ перехід на зимовий час
const afterShift = beforeShift.add({ weeks: 1 });

console.log(afterShift.toString());
// 2026-10-29T10:00:00+01:00[Europe/Madrid]
// 10:00 на стіні збережено, offset коректно змінився +02:00 → +01:00

// Погляд лондонського учасника
console.log(beforeShift.withTimeZone('Europe/London').toString());
// 2026-10-22T09:00:00+01:00[Europe/London]  (BST)
console.log(afterShift.withTimeZone('Europe/London').toString());
// 2026-10-29T09:00:00+00:00[Europe/London]  (GMT)
```

Арифметика пройшла через DST-межу і зробила саме те, що мали на увазі люди: «десята ранку в четвер» лишилась десятою. А конвертація `withTimeZone` чесно показує той самий фізичний момент очима Лондона. Той самий `epochNanoseconds`, різні стіни – і це видно в типах, а не в коментарях.

Бонус, який `Date` не вмів ніколи: Temporal знає про **неіснуючі та подвійні години**. 25 жовтня 02:30 у Мадриді трапляється двічі – `ZonedDateTime.from(..., { disambiguation: 'earlier' | 'later' })` змушує тебе вирішити це явно, а не отримати «якусь» із двох.

## Parsing, форматування, сумісність

- Парсинг – строгий: `Temporal.PlainDate.from('2026-13-45')` кидає помилку, а не «переносить» місяці, як `Date`.
- Форматування для людей – той самий `Intl`: `meeting.toLocaleString('uk-UA', {...})`.
- Міст до старого світу: `instant.epochMilliseconds` ↔ `Temporal.Instant.fromEpochMilliseconds(date.getTime())`. Повного «Date.toTemporal()» не чекай – міст свідомо вузький.

## Міграція без big bang

Переписувати весь проєкт – не треба. Робочий порядок:

1. **Нова логіка** з датною арифметикою/зонами – одразу на Temporal.
2. **Межі системи** (JSON API, база) лишаються ISO-рядками – Temporal чудово їх parse-ить і serialize-ить (`toString()` дає ISO з назвою зони у квадратних дужках – для зовнішніх API зазвичай шлють instant + окремо зону).
3. **Старі болючі місця** (усе, де в коді є `setHours`, ручні `* 60 * 1000` чи «+1 день» через мілісекунди) – мігруй при першому ж дотику: це найкращий ROI.
4. У [тестах](/blog/react-testing-strategy) фіксуй DST-кейси явно: 25.10.2026 і 29.03.2026 – готові дати для фікстур.

**Чесно про підтримку поза Node 26:** за MDN, у браузерах Temporal досі «limited availability» – у стабільних релізах основних браузерів його ще немає, для фронтенду потрібен polyfill (`@js-temporal/polyfill` або `temporal-polyfill`). Старі Node LTS (20/22/24) – теж тільки з polyfill або за флагом у пізніх 24.x. Тож «бекенд на Node 26 – нативно, shared-код із фронтом – поки через polyfill» – нормальна стратегія на найближчий рік.

## Типові помилки

- **Instant для подій «за стіною».** Зустрічі, розклади, «щодня о 9» – це ZonedDateTime/PlainTime; Instant тут і є той баг зі зсувом на годину.
- **ZonedDateTime для логів.** Навпаки теж боляче: лог – це момент; тягати зону немає сенсу і дорожче в зберіганні.
- **Своя арифметика поверх epochMilliseconds.** `+ 7 * 24 * 60 * 60 * 1000` – саме той код, який Temporal прийшов поховати.
- **Ігнор disambiguation.** Опції `'earlier'/'later'/'compatible'` існують, бо подвійна година реальна; дефолт `'compatible'` – ок, але для білінгу обирай свідомо.
- **Очікування Temporal у браузері без polyfill.** Перевір таргети перед тим, як шарити датну логіку між Node і фронтом.

## Практичне завдання

Візьми з свого проєкту одну функцію, що робить датну арифметику через `Date` (пошукай `setDate`, `getTime() +`, `* 86400`). Перепиши її на Temporal, обравши правильний тип за таблицею вище. Потім напиши один тест на 25.10.2026 через межу переведення годинників – і подивись, чи стара реалізація його проходила. Для бекендерів це ще й чудова [тема на співбесіді](/blog/nodejs-backend-interview): питання «Instant чи ZonedDateTime для X?» миттєво показує, хто працював із таймзонами, а хто лише чув про них. А якщо твій API приймає дати від клієнтів із retry – перевір заодно [ідемпотентність](/blog/api-idempotency-retries): дубль запиту «створити зустріч» через DST-годину – особливо підступний.
