Skip to content

Repository files navigation

temporal-fmt

Format Temporal.PlainDate / PlainTime / PlainDateTime / ZonedDateTime objects using date-fns-style token strings.

Node 26 shipped native Temporal — and then pointedly left out a custom-string formatter. TC39's take: use Intl.DateTimeFormat and leave string-token syntax to userland. Fair enough, but if you've spent years typing 'yyyy-MM-dd' out of muscle memory from date-fns, moment, or dayjs, that's a rough adjustment. This library exists so you don't have to make it.

Zero dependencies. You'll need a global Temporal — native on Node 26+, or bring your own polyfill (temporal-polyfill works fine).

Install

npm install temporal-fmt

View on npm

Usage

import { format } from 'temporal-fmt';

const date = Temporal.PlainDate.from('2026-08-04');
format(date, 'yyyy-MM-dd');           // "2026-08-04"
format(date, 'MMMM d, yyyy');         // "August 4, 2026"

const dt = Temporal.PlainDateTime.from('2026-08-04T15:45:30');
format(dt, "MMM d, yyyy 'at' h:mm a"); // "Aug 4, 2026 at 3:45 PM"

const zdt = Temporal.ZonedDateTime.from('2026-08-04T15:45:30-04:00[America/New_York]');
format(zdt, 'yyyy-MM-dd HH:mm zzz');   // "2026-08-04 15:45 America/New_York"

Wrap literal text in single quotes, like 'at' above. Need an actual single quote in your output? Use ''.

Parsing a string

parse builds a Temporal.PlainDate / PlainTime / PlainDateTime / ZonedDateTime out of a string, picking whichever type fits the tokens present:

import { parse } from 'temporal-fmt';

parse('yyyy-MM-dd HH:mm', '2026-08-04 15:45');    // Temporal.PlainDateTime
parse('yyyy-MM', '2026-08-04T15:45:30');          // throws — shape doesn't match
parse('yyyy-MM-dd', '2026-02-30');                // throws — not a real date

Because the format is unknown at runtime you will need to check the result with instanceof, or manually assert/type guard it in Typescript, to narrow the type.

Since parse constructs a real value rather than just matching shape, it catches an impossible date like February 30th, or a weekday name that doesn't match the date it's paired with:

parse('EEEE, yyyy-MM-dd', 'Tuesday, 2026-08-04');  // fine — that really is a Tuesday
parse('EEEE, yyyy-MM-dd', 'Monday, 2026-08-04');   // throws — it isn't

parse throws when input doesn't match formatStr's shape at all or throws a descriptive error if the computed date is not valid.

A few things worth knowing:

  • yy (2-digit year) emulates POSIX-style strptime: 00–68 becomes 2000–2068, 69–99 becomes 1900–1999.
    • this is an opinionated tradeoff but ensures yy is deterministic without an external date reference
  • hh/h (12-hour) without an a token throws — If both HH/H and a are present, the 24-hour value wins and a isn't cross-checked against it.
  • MMMM/MMM name matching assumes a 12-month calendar — the vocabulary it matches against is generated from 12 Gregorian reference dates, so a calendar with a leap month (e.g. Hebrew's 13-month leap years) isn't fully covered by month names. Numeric yyyy-MM-dd round-trips aren't affected.

Locale support

Pass a BCP 47 locale tag as a third argument and month names, weekday names, and AM/PM markers all localize accordingly. Defaults to 'en-US' if you don't.

format(date, 'MMMM d, yyyy', { locale: 'fr-FR' });   // "août 4, 2026"
format(date, 'EEEE d MMMM', { locale: 'ar-EG' });    // Arabic weekday/month names
format(dt, 'h:mm a', { locale: 'ja-JP' });            // "3:45 午後"

The named fields (MMMM, MMM, EEEE, EEE, a) go through Intl.DateTimeFormat under the hood, which means non-Gregorian calendars work too, as long as the Temporal object is already carrying one:

const hebrewDate = date.withCalendar('hebrew');
format(hebrewDate, 'MMMM d, yyyy');   // "Av 21, 5786"

The above holds true for parse as well:

parse('MMMM d, yyyy','août 4, 2026', { locale: 'fr-FR' });
parse('h:mm a', '3:45 午後', { locale: 'ja-JP' });
// `-u-ca-` calendar extension parses into that calendar
parse('yyyy-MM-dd', '5786-11-21', { locale: 'en-u-ca-hebrew' });

Numeric fields (yyyy, MM, dd, HH, mm, ss, SSS) always come out in Western (0-9) digits, no matter what locale you pass. On purpose. Most things reading this output back in — logs, APIs, filenames — want boring, predictable ASCII digits, and locale-native numeral systems like Arabic-Indic or Devanagari don't play nicely with this library's zero-padding logic anyway. Need localized digits? Run the numeric pieces through Intl.NumberFormat yourself.

One more catch: this needs native Intl/Temporal interop to work. On Node 26+ with native Temporal, you're fine. On older Node with a userland polyfill, locale-aware tokens will throw — unless you swap in the polyfill's own Intl export in place of the global one. Why? Because Intl.DateTimeFormat can't read fields off a non-native Temporal object; you'll get a Cannot use valueOf error for your trouble. That's a limitation baked into how Intl and Temporal currently talk to each other, not something this library can paper over.

Tokens

Token Meaning Example
yyyy 4-digit year 2026
yy 2-digit year 26
MMMM full month name August
MMM short month name Aug
MM 2-digit month 08
M month 8
dd 2-digit day 04
d day 4
EEEE full weekday Tuesday
EEE short weekday Tue
HH 2-digit hour (24h) 15
H hour (24h) 15
hh 2-digit hour (12h) 03
h hour (12h) 3
mm 2-digit minute 45
m minute 45
ss 2-digit second 30
s second 30
SSS milliseconds 000
a AM/PM PM
zzz IANA time zone id America/New_York

Try to use a token your input type doesn't support — HH on a PlainDate, say — and you'll get a real error telling you so, not a silent undefined sitting in your output waiting to confuse someone in three weeks.

Known limitations

  • Numeral systems are always Western digits — see Locale support.
  • Requires native Temporal/Intl interop (Node 26+) for locale-aware tokens.

Dev notes

tsconfig.json sets ignoreDeprecations: "6.0" to work around a tsup bug (tsup#1388/#1389). tsup's dts build step quietly injects a deprecated baseUrl, and TypeScript 6+ hard-errors on it. Workaround, not a fix — drop it the moment tsup ships a real one upstream.

Tests pull from temporal-polyfill/full, not the slim temporal-polyfill — the Hebrew-calendar test needs the full build's calendar data, and the slim one won't cut it. On Node < 26 without native Temporal, expect the locale-aware tests to fail with Cannot use valueOf. Same polyfill/Intl interop gap mentioned above, not a bug in the tests. Clean pass on Node 26+.

License

MIT

About

date-fns-style formatter for PlainDate, PlainTime, PlainDateTime & ZonedDateTime. Zero deps.

Topics

Resources

Security policy

Stars

7 stars

Watchers

3 watching

Forks

Releases

Packages

Contributors

Languages