tz-zoned-time
v0.0.1
Published
Zero-dependency timezone math: convert wall-clock date and time in any IANA time zone to a UTC instant and back, DST-safe, using only Intl. Works in Cloudflare Workers, browsers, and Node.js.
Maintainers
Readme
tz-zoned-time
Zero-dependency timezone math for JavaScript and TypeScript. Convert a wall-clock date and time in any IANA time zone ("Asia/Seoul", "America/New_York") to a UTC instant and back, handle daylight saving time (DST) transitions predictably, and store date-only values without off-by-one-day bugs. It uses only the built-in Intl API, so the same code runs in Cloudflare Workers, edge and serverless runtimes, browsers, and Node.js. Use it as a small, isomorphic alternative to date-fns-tz or Luxon when you only need time zone conversion.
Why
Two things are easy to confuse:
- Wall-clock time: the numbers a person reads off a clock ("9:00 AM"). Without a time zone it doesn't name a moment. 9:00 in Seoul and 9:00 in New York are different moments.
- Instant: a single point in time, the same everywhere on Earth. You carry it around as a UTC
Date.
If you convert between them with new Date(...) and local getters such as getDate(), the result depends on the runtime's time zone. Workers run in UTC and browsers run in the user's local zone, so the same code can shift dates by a day. tz-zoned-time gives the server and the client one shared, deterministic implementation.
Use cases
- Scheduling: a user picks "Sep 1, 9:00 AM in Asia/Seoul". Turn it into a UTC instant for your database.
- Displaying events: show a stored instant as the date and time in the viewer's time zone, for example in a calendar or a reminder list.
- Date-only fields (due dates, birthdays): store them as a stable instant and read the same calendar date back from any time zone.
- APIs, logs, and LLM prompts: format an instant as an ISO 8601 string with the local offset, such as
2026-09-02T10:30:00+09:00.
Install
npm install tz-zoned-timeUsage
Instant → wall-clock time in a time zone (for display)
import { dateInTz, timeInTz, tzOffsetMs } from "tz-zoned-time";
const instant = new Date("2026-08-19T15:30:00Z");
dateInTz(instant, "Asia/Seoul"); // → { year: 2026, month: 8, day: 20 }
timeInTz(instant, "Asia/Seoul"); // → { hour: 0, minute: 30 }
tzOffsetMs(instant, "Asia/Seoul"); // → 32400000 (+9h in ms)Wall-clock time in a time zone → instant (for storage)
import { combineDateAndTime } from "tz-zoned-time";
combineDateAndTime(
{ year: 2026, month: 9, day: 1 },
{ time: { hour: 9, minute: 0 }, timezone: "Asia/Seoul" },
);
// → 2026-09-01T00:00:00.000ZISO 8601 string with the local offset
import { zonedIsoString } from "tz-zoned-time";
zonedIsoString(new Date("2026-09-02T01:30:00Z"), "Asia/Seoul");
// → "2026-09-02T10:30:00+09:00"Date-only values
import { encodeCalendarDate, calendarDateOf } from "tz-zoned-time";
const stored = encodeCalendarDate({ year: 2026, month: 8, day: 20 });
// → 2026-08-20T00:00:00.000Z
calendarDateOf(stored); // → { year: 2026, month: 8, day: 20 }API
| Function | Description |
| --- | --- |
| dateInTz(date, timezone) | Calendar date (CalendarDate) of an instant in the given time zone. |
| timeInTz(date, timezone) | Wall-clock time (TimeOfDay) of an instant in the given time zone. |
| tzOffsetMs(date, timezone) | How far the zone's clock is ahead of UTC at that instant, in ms (Asia/Seoul → 32400000). The sign is the opposite of Date#getTimezoneOffset(). |
| combineDateAndTime(date, { time, timezone }) | The UTC instant for a calendar date plus a wall-clock time in a time zone. |
| zonedIsoString(date, timezone) | ISO 8601 string with the local time and offset, to the second. |
| encodeCalendarDate(date) | Encodes a calendar date as UTC midnight of that day for storage. |
| calendarDateOf(instant) | Decodes a stored instant back into a calendar date. Doesn't depend on any time zone. |
Daylight saving time
combineDateAndTime computes the offset twice: once from a first guess, then again at the guessed moment. On DST transition days it follows one rule: pick the earlier instant for both skipped times (spring forward) and repeated times (fall back).
// 02:30 doesn't exist in New York on 2026-03-08 → 01:30 EST
combineDateAndTime(
{ year: 2026, month: 3, day: 8 },
{ time: { hour: 2, minute: 30 }, timezone: "America/New_York" },
); // → 2026-03-08T06:30:00.000Z
// 01:30 happens twice on 2026-11-01 → the first one (EDT)
combineDateAndTime(
{ year: 2026, month: 11, day: 1 },
{ time: { hour: 1, minute: 30 }, timezone: "America/New_York" },
); // → 2026-11-01T05:30:00.000ZDecoding date-only values
calendarDateOf returns the UTC year, month, and day of instant + 12h ("round to noon"). Values from encodeCalendarDate always round-trip exactly. It also recovers older data saved as browser-local midnight, as long as the zone offset is in (−12h, +12h].
Known limitations: legacy local-midnight values written in UTC+13/+14 (for example Pacific/Kiritimati), and legacy values that aren't midnight and fall late in the local evening, can come back one day off.
Types
interface CalendarDate { year: number; month: number; day: number } // month is 1-based (1–12)
interface TimeOfDay { hour: number; minute: number } // hour is 0–23
type ZonedTimeOfDay = { time: TimeOfDay; timezone: string } // IANA zone name
monthis 1-based, unlike the 0-based month in JavaScript'sDate.
License
MIT
