Pięć pułapek API czasowych, które po cichu ponownie wprowadzają błędy związane z datami
Dowiedz się, jak API temporalne JavaScripta może nadal powodować błędy związane z strefami czasowymi, serializacją, dokładnością i czasem trwania, chyba że zostaną skorygowane pięć powszechnych błędnych wzorców użycia.
Źródła: JavaScript Temporal API: 5 Critical Mistakes and How to Fix Them!
Powiadomienie przypominające, które pojawia się co dzień godzinę przed zmianą czasu zimowego. Zgłoszenie techniczne zapisane pod niewłaściwym dniem kalendarzowym, ponieważ serwer działa w czasie UTC, podczas gdy klient znajduje się w zupełnie innej strefie czasowej. Raport finansowy, który od czasu do czasu nie przechodzi testów równości w środowisku CI, bez możliwości odtworzenia błędu na lokalnym komputerze.
Żaden z tych problemów nie jest spowodowany klasą Date. Są to błędy temporalne, które pojawiają się w kodach, w których zastosowano Temporal właśnie po to, by uniknąć problemów związanych z datami.
Od wersji Node.js 26.8.2 Temporal jest dostępny globalnie bez konieczności użycia żadnego flagi eksperymentalnej. Chrome 144 obsługuje go od razu po instalacji. TC39 przeniosło tę propozycję do etapu 4, co oznacza, że nie jest to już tylko hipotetyczne rozszerzenie przyszłości – stanowi już ustalone element języka, a zespoły aktywnie używają go do zastąpienia Moment.js, date-fns oraz własnej arytmetyki opartej na klasie Date. Właśnie w ramach tej fali migracji zwykle pojawiają się pięć poniższych błędów.
Temporal rozwiązuje stare problemy związane z klasą Date — zmienność, niejednoznaczne parsowanie oraz brak wyraźnej granicy pomiędzy datą kalendarzową a dokładną chwilą w czasie — poprzez wprowadzenie bardziej rygorystycznego i jasnego modelu danych. Problemy pojawiają się, gdy programiści traktują tę klasę jedynie jako „lepszą wersję Date”. To założenie powoduje błędy opisane tutaj, a te błędy są zazwyczaj bardziej podstępne niż stare, nie łatwiejsze do wykrycia.
Błąd 1: Wywołanie Temporal.Now.plainDateISO() bez określenia, co rozumie się przez „dzisiaj”
To prawdopodobnie najczęstszy błąd, głównie dlatego, że kod, który go powoduje, na pierwszy rzut oka wygląda zupełnie poprawnie.
// Inside an API route running on a server in us-east-1
function getTodaysDigestDate() {
const today = Temporal.Now.plainDateISO();
return today.toString();
}
Wywołanie Temporal.Now.plainDateISO() bez żadnych argumentów ustala wartość „teraz” na podstawie strefy czasowej systemu — czyli takiej, w której działa serwer, a nie tej, w której znajduje się użytkownik końcowy. Wyobraźmy sobie klienta w Tokio, który otwiera twoją aplikację o 8 rano czasu lokalnego: jeśli serwer znajduje się w UTC i jeszcze nie minęła północ, dane tego klienta zostaną oznaczone datą z wczoraj. To ten sam problem błędnego odczytu o jeden element, który kiedyś plątał new Date().toDateString() — po prostu pojawił się ponownie w kodzie, o którym deweloperzy sądzili, że jest od niego wolny, tylko dlatego, że używa Temporal.
Lepsze rozwiązanie:
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 nigdy nie rozwiązał problemu „w odniesieniu do której strefy?„ — jedynie zmusza do jego jawnej odpowiedzi zamiast pozwalać na milczące przyjęcie domyślnego wartości. Każde wywołanie Temporal.Now.*, w którym pominięto jasno określoną strefę, jest decyzją podjętą bez świadomości tego. Warto przeszukać kod bazowy w poszukiwaniu nieokreślonych wywołań Temporal.Now.plainDateISO(), Temporal.Now.zonedDateTimeISO() oraz Temporal.Now.plainDateTimeISO() — każde z nich w tajemnicy odpowiada na pytanie „dzisiaj, według kogo?„ jako „w zależności od strefy, w której aktualnie działa serwer„.
Błąd 2: Przypuszczanie, że przesyłanie danych w formacie JSON zamienia wartość Temporal z powrotem na obiekt Temporal
Obiekty temporalne udostępniają metodę toJSON(), więc przekazywanie ich do JSON.stringify() wydaje się działać bezproblemowo — a to pozorne sukces jest właśnie tym, co powoduje powstanie poniższego błędu.
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 w rzeczywistości wywołuje toJSON() i tworzy zwykłą ciąg znaków w formacie ISO. Jednak JSON.parse nie ma sposobu, by dowiedzieć się, że dany ciąg znaków reprezentował klasę Temporal.PlainDate — po prostu zwraca ciąg znaków, a nie instancję klasy. Uruchomienie powyższego fragmentu kodu powoduje błąd TypeError: restored.renewalDate.since is not a function, co zazwyczaj dzieje się w środowisku produkcyjnym, dopiero po tym, jak wartość przeszła przez jakiś mechanizm serializacji: localStorage, wywołanie postMessage do Web Workera, przekazanie właściwości do komponentu serwerowego lub zapisany magazyn Redux. Podczas lokalnego rozwoju, gdy praca odbywa się w tej samej karcie i w tym samym cyklu wykonywania, ten problem łatwo umknąć uwadze — pojawia się dopiero wtedy, gdy wartość zostanie faktycznie zserializowana i później odczytana.
Lepsze rozwiązanie:
const restored = JSON.parse(localStorage.getItem("sub"));
const renewalDate = Temporal.PlainDate.from(restored.renewalDate);
const daysLeft = renewalDate.since(Temporal.Now.plainDateISO()).days;
Zasada jest następująca: każdy typ Temporal zawiera odpowiadający mu statyczny konstruktor .from() służący do rekonstrukcji, a gdy którykolwiek z tych wartości trafia najpierw do warstwy, która przekształca ją w zwykły tekst lub JSON — czy to w pamięci przeglądarki, wiadomości roboczej, treści API, czy przy przenoszeniu danych między serwerem a klientem w ramach frameworka — to ty jesteś odpowiedzialny za jej wyraźną konwersję z powrotem po dotarciu na miejsce. Nic nie wykonuje tej rekonstrukcji automatycznie, a nic też nie wyświetla ostrzeżenia, gdy ten krok zostanie pominęty.
Błąd 3: Zapomnienie, że baza danych nie może dorównać precyzji Temporal
Temporal.Instant mierzy czas z dokładnością do nanosekundy. Praktycznie żaden z systemów, którym przekazujesz tę wartość, nie jest w stanie zachować tak wysokiego poziomu szczegółowości.
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
Rodzaj kolumny timestamptz w Postgresie zachowuje jedynie precyzję do mikrosekundy, natomiast zwykła struktura Date w JavaScriptie przechowuje tylko milisekundy. Jeśli zapisać wartość typu Instant o precyzji nanosekundy bezpośrednio do jednej z tych struktur, dodatkowe cyfry są cicho odcinane podczas operacji wstawiania. Porównanie przedstawione w powyższym fragmencie nie jest technicznie błędne — po prostu porównuje wartość z poziomem precyzji, którego przechowywanie w bazie danych nigdy nie było możliwe. Jeśli tego nie zauważyć, może to skutkować przerywanymi testami, kontrolą eliminacji duplikatów, która pozwala czasem „duplikatom” przejść niezauważone, lub ścieżką audytową, w której dwa zdarzenia występujące w tej samej mikrosekundzie stają się nierozróżnialne, mimo że nikt nie zamierzał takiego wyniku.
Prawidłowe użycie:
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
Zaokrągluj wartość w dół do takiej precyzji, jaką faktycznie obsługuje warstwa przechowywania, zanim ją zapiszesz, a nie po tym, jak stracisz czas na próby porównania, które zawodzą jedynie sporadycznie.
Błąd 4: Przechowywanie stałej daty dla wydarzenia, które jeszcze się nie wydarzyło
Błąd ten jest szczególnie niebezpieczny, ponieważ zazwyczaj przechodzi testy i trafia do produkcji – awaria pojawia się tylko dwa razy w roku.
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 oznacza pojedynczy, stały moment na osi czasu UTC; po przekształceniu lokalnego czasu w taki obiekt, on już nie „pamięta”, że odpowiadał on godzinie 9:00 czasu lokalnego. Jeśli wyznaczona data przypada po zmianie strefy czasowej związanej z letnim lub zimowym czasem w stosunku do chwili utworzenia przypomnienia, offset UTC dla danej strefy ulegnie zmianie, w wyniku czego powiadomienie zostanie wysłane o godzinie 8:00 lub 10:00 czasu lokalnego zamiast o 9:00. W momencie wykonywania kodu nie ma w nim żadnych błędów — problem pojawia się dopiero po zmianie godzin, co jest dokładnie tym rodzajem usterki, którą określa się mianem „niestabilnej” i która pojawia się ponownie co wiosnę i jesienią.
Prawidłowe użycie:
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();
}
Gdy tak naprawdę masz na myśli „9:00 rano czasu lokalnego, niezależnie od wartości offsetu”, zachowaj wartość z zegara ściennego wraz z strefą czasową i przekonwertuj ją na konkretny obiekt Instant dopiero w ostatniej chwili — tuż przed podjęciem działań, a nie w momencie planowania.
Błąd 5: Używanie obliczeń w milisekundach zamiast Temporal.Duration
Nawyki przyjęte od metody Date.getTime() nie znikają automatycznie tylko dlatego, że przejściem na nowy typ.
function oneWeekLater(zonedDateTime) {
const msPerWeek = 7 * 24 * 60 * 60 * 1000;
const nextMs = zonedDateTime.epochMilliseconds + msPerWeek;
return Temporal.Instant.fromEpochMilliseconds(nextMs)
.toZonedDateTimeISO(zonedDateTime.timeZoneId);
}
Tydzień kalendarzowy nie zawsze wynosi dokładnie 168 godzin. Gdy zakres, który obliczasz, przechodzi przez granicę zmiany czasu letniego, dodanie do wartości epoki milisekund odpowiadających 7 * 24 godzinom daje błędny lokalny czas — umowa zaplanowana na „tydzień później” przesuwa się o godzinę, mimo że sama data kalendarzowa pozostaje niezmieniona. Jest to w istocie łagodniejsza wersja Błędu 4 i ma tendencję do ponownego pojawiania się w każdej funkcji nadal opartej na surowej arytmetyce milisekund, po prostu dlatego, że tak właśnie kiedyś wykonywano obliczenia związane z datami.
Prawidłowe użycie:
function oneWeekLater(zonedDateTime) {
return zonedDateTime.add({ weeks: 1 });
}
Metody takie jak .add() i .subtract(), zbudowane wokół Temporal.Duration, uwzględniają kalendarz oraz strefy czasowe, dzięki czemu wyrażenie „jedna tydzień później” poprawnie odpowiada tej samej godzinie zegara na tym samym dniu tygodnia, włączając czas letni. Gdy zaczynasz obliczać surowy odstęp w milisekundach, a następnie go konwertować, wychodzisz poza zakres API Temporal przeznaczonego do prawidłowego radzenia sobie z takimi zadaniami.
Co łączy wszystkie pięć błędów
Każdy z tych problemów ma swoje źródło w tej samej przyczynie podstawowej: Temporal zmusza nas do jasnego określenia strefy czasowej, precyzji i intencji, a każdy błąd wynika z cichego korzystania z domyślnych założeń, które implementuje klasa Date. Temporal nigdy nie miał być prostym zamiennikiem – to bardziej rygorystyczny model, który wykorzystuje nazwy metod tylko pozornie podobne do tych z klasą Date.
Szybki podsumowanie:
- Nigdy nie wywołuj
Temporal.Now.*, nie określając jasno, której strefy czasowej „teraz” faktycznie potrzebujesz - Przywracaj wartości Temporal za pomocą metody
.from()po obu stronach granicy serializacji – JSON zamienia je na zwykłe łańcuchy znaków, a nie na żywe obiekty, podczas procesu przesyłania danych
Instant dopiero w momencie, gdy zamierzasz podjąć wobec niego działanieTemporal.Duration — .add(), .subtract() — zamiast obliczeń w milisekundach od epoki dla wszystkiego, co dotyczy dat kalendarzowychTemporal rzeczywiście skutecznie rozwiązuje problemy zgromadzone przez ponad dekadę związane z Date. Jednak w zamian za to stare zamieszanie otrzymujemy mniejszą liczbę, ale znacznie ostrzejszych problemów, a teraz, gdy zarówno Node 26, jak i Chrome 144 mają go domyślnie włączone, coraz więcej zespołów napotka te problemy bezpośrednio w produkcji podczas migracji.
Literatura pokrewna
- Propozycje TC39 w 2026 roku: Dekoratory, Temporal i sygnały wyjaśnione — Praktyczny przegląd trzech propozycji TC39 – natywnych dekoratorów, API Temporal oraz sygnałów – i tego, co oznaczają one dla programistów JavaScript i TypeScript pracujących w modelu full-stack.
- Jak process.nextTick() cicho wygłodza pętlę zdarzeń Node.js — Wyjaśnia, dlaczego rekurencyjne wywołania process.nextTick() całkowicie blokują fazę poll w libuv oraz jak naprawić problem wygłodzenia pętli zdarzeń za pomocą setImmediate().