isotropic-temporal-format
v0.1.0
Published
Format and parse Temporal values with template patterns
Maintainers
Readme
isotropic-temporal-format
Formats Temporal values to strings and parses string to Temporal values with custom template patterns.
Why Use This?
- Familiar Patterns: Uses the same
to_char/to_timestamptemplate patterns as PostgreSQL (YYYY,HH24,MI,FF6,Month,Day, and so on) - Every Temporal Type: Formats
Instant,ZonedDateTime,PlainDateTime,PlainDate,PlainTime,PlainYearMonth,PlainMonthDay, andDuration - Two-Way:
toStringrenders a Temporal value to a string andfromStringparses a string back into a Temporal value - Built on Temporal: Uses the native
TemporalAPI for accurate, dependency-free date and time math - Type-Checked: A pattern that cannot apply to a value (a time pattern on a
PlainDate, an offset on aPlainTime) raises aFormatErrorinstead of silently emitting the pattern name - Extensible: Patterns are ordinary objects, so you can add your own by subclassing with an additional pattern set (see Custom Patterns)
Requirements
This package depends on the global Temporal object and ships as native ES modules. It targets newer Node.js versions, where Temporal is available without a flag.
Installation
npm install isotropic-temporal-formatUsage
import _TemporalFormat from 'isotropic-temporal-format';
// Format a PlainDateTime
_TemporalFormat.toString({
format: 'YYYY-MM-DD HH24:MI:SS',
value: Temporal.PlainDateTime.from('2024-01-15T09:30:45')
});
// '2024-01-15 09:30:45'
// Format a Duration (sub-day units fold into hours)
_TemporalFormat.toString({
format: 'HH24:MI:SS',
value: Temporal.Duration.from({
minutes: 150
})
});
// '02:30:00'
// Parse a string back into a Temporal value
_TemporalFormat.fromString({
format: 'YYYY-MM-DD"T"HH24:MI:SS',
string: '2024-01-15T09:30:45',
typeName: 'PlainDateTime'
});
// Temporal.PlainDateTime <2024-01-15T09:30:45>API
fromString(options)
Parses a string into a Temporal value.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| format | string | — | A template string describing the input |
| strict | boolean | false | Opt-in to strict parsing |
| string | string | — | The input to parse |
| typeName | string | 'Instant' | The name of the Temporal type to produce |
Returns an instance of the requested type.
typeName is the string name of the target type: 'Duration', 'Instant', 'PlainDate', 'PlainDateTime', 'PlainMonthDay', 'PlainTime', 'PlainYearMonth', or 'ZonedDateTime'.
toString(options)
Renders a Temporal value to a string.
| Option | Type | Description |
| --- | --- | --- |
| format | string | A template string of patterns, modifiers, and literal text |
| value | Temporal.* | Any supported Temporal value |
Returns the formatted string.
value may be a Temporal.Duration, Temporal.Instant, Temporal.PlainDate, Temporal.PlainDateTime, Temporal.PlainMonthDay, Temporal.PlainTime, Temporal.PlainYearMonth, or Temporal.ZonedDateTime.
Format Patterns
These mostly follow PostgreSQL's date/time template patterns. Case matters: the case of a name pattern (such as Month versus MONTH) controls the case of the output.
Hour, minute, second
| Pattern | Description |
| --- | --- |
| HH24 | Hour of day on a 24-hour clock (00-23); for a Duration, the total folded hours, which may exceed 23 |
| HH, HH12 | Hour of day on a 12-hour clock (01-12) |
| MI | Minute (00-59) |
| SS | Second (00-59) |
| SSSS, SSSSS | Seconds past midnight; for a Duration, total folded seconds |
| FF1 | Tenth of Second (0-9) |
| FF2 | Hundredth of Second (00-99) |
| FF3, MS | Millisecond (000-999) |
| FF4 | Tenth of Millisecond (0000-9999) |
| FF5 | Hundredth of Millisecond (00000-99999) |
| FF6, US | Microsecond (000000-999999) |
| FF7 | Tenth of Microsecond (0000000-9999999) |
| FF8 | Hundredth of Microsecond (00000000-99999999) |
| FF9, NS | Nanosecond (000000000-999999999) |
FF7, FF8, FF9, and NS are extensions beyond PostgreSQL, which stops at microseconds; Temporal values carry nanosecond precision, so the full resolution is available here.
Fractional seconds are rounded, not truncated. FF1 through FF6, MS, and US first round the subsecond value to microseconds and then take the leading digits, matching PostgreSQL's to_char, so .123456789 renders as 123457 with FF6 and 1234 with FF4. FF7 through FF9 round at their own width. Rounding never carries into the seconds: .999999999 renders as 999999 with FF6, not 000000.
Meridiem
| Pattern | Description |
| --- | --- |
| AM, PM | Uppercase meridiem indicator (AM/PM) |
| am, pm | Lowercase meridiem indicator (am/pm) |
| A.M., P.M. | Uppercase meridiem indicator with periods |
| a.m., p.m. | Lowercase meridiem indicator with periods |
AM and PM are interchangeable on input and both render the value's actual meridiem.
Year
| Pattern | Description |
| --- | --- |
| YYYY | Year, four or more digits |
| YYY, YY, Y | Last 3, 2, or 1 digits of the year |
| Y,YYY | Year with a comma thousands separator |
| IYYY | ISO 8601 week-numbering year |
| IYY, IY, I | Last 3, 2, or 1 digits of the ISO year |
| CC | Century |
Era
| Pattern | Description |
| --- | --- |
| AD, BC | Uppercase era indicator |
| ad, bc | Lowercase era indicator |
| A.D., B.C. | Uppercase era indicator with periods |
| a.d., b.c. | Lowercase era indicator with periods |
The era patterns are interchangeable on input and all render the value's actual era. Years are interpreted astronomically: year 0 renders as 1 BC, year -1 as 2 BC, and so on.
Month
| Pattern | Description |
| --- | --- |
| MM | Month number (01-12) |
| MONTH, Month, month | Full month name, blank-padded to nine characters (Use the FM modifier to remove the padding.) |
| MON, Mon, mon | Three-letter month abbreviation |
| RM, rm | Month in Roman numerals (I-XII / i-xii), blank-padded to four characters (Use the FM modifier to remove the padding.) |
| Q | Quarter of the year (1-4) |
Day
| Pattern | Description |
| --- | --- |
| DD | Day of month (01-31) |
| DDD | Day of year (001-366) |
| IDDD | ISO day of year (001-371) |
| D | Day of week, Sunday = 1 through Saturday = 7 |
| ID | ISO day of week, Monday = 1 through Sunday = 7 |
| DAY, Day, day | Full weekday name, blank-padded to nine characters (Use the FM modifier to remove the padding.) |
| DY, Dy, dy | Three-letter weekday abbreviation |
| J | Julian day (number of days since November 24, 4714 BC, in the proleptic Gregorian calendar) |
Week
| Pattern | Description |
| --- | --- |
| W | Week of month (1-5) |
| WW | Week of year (01-53) |
| IW | ISO week of year (01-53) |
Time zone
These apply only to Temporal.Instant and Temporal.ZonedDateTime.
| Pattern | Description |
| --- | --- |
| TIMEZONE, TimeZone, timezone | Time zone name (for example America/New_York / america/new_york) |
| TZ, tz | Time zone abbreviation, uppercase or lowercase (for example EST / est) |
| TZH | Time zone offset hours (for example +05) |
| TZM | Time zone offset minutes (for example 30) |
| OF | Time zone offset (for example +05:30, or +00 when there are no offset minutes) |
TimeZone renders the value's time zone identifier, with the pattern's case controlling the output case. TZ renders the localized abbreviation (EST, PDT, GMT+5:30, and so on).
Unlike PostgreSQL, which stores only offsets, these patterns also parse time zone names (see Time zone parsing).
Modifiers
| Pattern | Position | Description |
| --- | --- | --- |
| FM | prefix | Fill mode: suppress leading zeros and blank padding for the pattern that immediately follows |
| FX | global prefix | Fixed format: switches parsing to consume input strictly by position (see Parsing) |
| TH, th | suffix | Append an uppercase or lowercase ordinal suffix (for example DDth produces 15th) |
| TM | prefix | Translation mode: accepted but not implemented, so it is ignored |
| SP | suffix | Spell mode: accepted but not implemented, so it is ignored |
_TemporalFormat.toString({
format: 'FMDay, FMMonth FMDDth, YYYY',
value: Temporal.PlainDate.from('2024-01-15')
});
// 'Monday, January 15th, 2024'Literals and Escapes
| Pattern | Description |
| --- | --- |
| "..." | Literal text, copied verbatim |
| \" | Literal quote character |
| \\ | Literal backslash, only recognized within quoted literal text (Outside of quoted literal text, backslashes act as regular characters and do not require escaping.)|
_TemporalFormat.toString({
format: '"Year:" YYYY',
value: Temporal.PlainDate.from('2024-01-15')
});
// 'Year: 2024'Type Validation
Each pattern declares the value components it needs, and each Temporal type declares the components it can provide. When a format uses a pattern the value cannot satisfy, toString and fromString both throw a FormatError rather than guessing. For example, time patterns have no meaning for a PlainDate:
_TemporalFormat.toString({
format: 'YYYY-MM-DD HH24:MI',
value: Temporal.PlainDate.from('2024-01-15')
});
// throws FormatError: pattern 'HH24' is not valid for the 'PlainDate' typeCalendars
Only the ISO 8601 calendar is supported. A value in any other calendar system raises a CalendarError; convert it with withCalendar('iso8601') first if that is your intent.
_TemporalFormat.toString({
format: 'YYYY-MM-DD',
value: Temporal.PlainDate.from('2024-01-15').withCalendar('hebrew')
});
// throws CalendarErrorInstants
A Temporal.Instant has no associated time zone, so it is rendered in UTC. To render an instant in another zone, convert it to a Temporal.ZonedDateTime first:
_TemporalFormat.toString({
format: 'YYYY-MM-DD HH24:MI:SS TZ',
value: Temporal.Instant.from('2024-01-15T14:30:00Z').toZonedDateTimeISO('America/New_York')
});
// '2024-01-15 09:30:00 EST'Durations
Temporal.Duration values are rendered with the same time and date patterns:
- Sub-day units (hours, minutes, seconds, and fractional seconds) are summed and redistributed.
HH24:MI:SSof a 25-hour-61-minute duration is26:01:00, andHH24may exceed23.HH/HH12still wrap to the 12-hour clock. - Weeks fold into days. Days are not folded into hours, so
DDshowsweeks * 7 + dayswhileHH24shows only the sub-day hours. - Months normalize into years:
YYYYis the whole number of years andMMis the remaining months. - The magnitude is rendered; the sign is dropped. Read
duration.signseparately if you need it. - Patterns that have no meaning for a duration (weekday and month names,
J,Q,TZ, week patterns, era indicators, and so on) raise aFormatError, since a duration carries no calendar date or wall-clock context to resolve them. The numericMMstill works because it is just the month count.
_TemporalFormat.toString({
format: 'DD "days" HH24:MI:SS',
value: Temporal.Duration.from({
days: 2,
hours: 25,
minutes: 61
})
});
// '02 days 26:01:00'Parsing
fromString reads a string according to a format and assembles a value of the requested typeName.
_TemporalFormat.fromString({
format: 'Month DD, YYYY',
string: 'March 15, 2026',
typeName: 'PlainDate'
});
// Temporal.PlainDate <2026-03-15>
_TemporalFormat.fromString({
format: 'IYYY-"W"IW-ID',
string: '2024-W03-1',
typeName: 'PlainDate'
});
// Temporal.PlainDate <2024-01-15>Missing fields take sensible defaults: month and day default to 1, all time fields and year default to 0, and the time zone to UTC. When a year is written with fewer than four digits, it is adjusted to the value nearest the year 2020, so YY of 95 parses as 1995 and 21 parses as 2021.
PostgreSQL Quirks
The goal of this package isn't to copy the implementation entirely from PostgreSQL. Temporal values are different from Postgres timestamp types so there are intentional functional differences. It reuses the familiar set of patterns so hopefully developers don't have to learn multiple entirely different timestamp templating systems. Because of this, it has also inherited some very specific parsing behavior.
When a format string includes text that is not one of the format patterns, whether quoted or not, it is treated as literal text. By default, literal text in the format string does not need to exactly match the input string. Parsing ignores runs of whitespace in the input string between values. Digit and letter characters will match any one character in the input string. (Any one character, they don't have to be the same character.) Non-digit and non-letter characters will match zero or one non-digit or non-letter characters in the input string. That's a weirdly specific rule, but generally it means that the number of separator characters in the input string must be less than or equal to the number of separator characters in the format string.
_TemporalFormat.fromString({
format: 'YYYY---MM---DD',
string: '2026---07---14',
typeName: 'PlainDate'
});
// This works! The literal text matches exactly.
// Temporal.PlainDate <2026-07-14>
_TemporalFormat.fromString({
format: 'YYYY---MM---DD',
string: '2026-07-14',
typeName: 'PlainDate'
});
// This also works! There are fewer separators in the input string.
// Temporal.PlainDate <2026-07-14>
_TemporalFormat.fromString({
format: 'YYYY FOO MM BAR DD',
string: '2026 ABC 07 XYZ 14',
typeName: 'PlainDate'
});
// This also works! The individual characters don't need to match.
// Temporal.PlainDate <2026-07-14>
_TemporalFormat.fromString({
format: 'YYYY-MM-DD',
string: '2026---07---14',
typeName: 'PlainDate'
});
// This doesn't work! There are fewer separators in the format string.
// throws ParseErrorA leading FX switches to fixed-format mode, where literal text is consumed strictly by position. Any non-pattern character in the format string will match any one character in the input string.
_TemporalFormat.fromString({
format: 'FXYYYY---MM---DD',
string: '2026---07---14',
typeName: 'PlainDate'
});
// This works! The literal text matches exactly.
// Temporal.PlainDate <2026-07-14>
_TemporalFormat.fromString({
format: 'YYYY FOO MM BAR DD',
string: 'FX2026 ABC 07 XYZ 14',
typeName: 'PlainDate'
});
// This also works! The individual characters don't need to match.
// Temporal.PlainDate <2026-07-14>
_TemporalFormat.fromString({
format: 'YYYY---MM---DD',
string: 'FX2026-07-14',
typeName: 'PlainDate'
});
// This doesn't work! The number of characters doesn't match.
// throws ParseErrorWhere PostgreSQL will error if the same pattern is filled twice with different values, TemporalFormat.fromString will allow it but the later value overwrites the former.
Beyond PostgreSQL
Several patterns that PostgreSQL's to_timestamp reads and discards are fully parsed here:
Jparses a Julian day number into a date:2451187parses as1999-01-08.SSSSandSSSSSparse seconds past midnight and decompose into hour, minute, and second. Explicitly parsed components win: inHH24:MI:SS SSSS, the clock fields take precedence.- Time zone names and abbreviations parse (see Time zone parsing).
FF7,FF8,FF9, andNSparse fractional seconds to full nanosecond precision.
Strict Mode
When strict mode is enabled, literal text must match exactly.
_TemporalFormat.fromString({
format: 'YYYY---MM---DD',
strict: true,
string: '2026---07---14',
typeName: 'PlainDate'
});
// This works! The literal text matches exactly.
// Temporal.PlainDate <2026-07-14>
_TemporalFormat.fromString({
format: 'YYYY FOO MM BAR DD',
strict: true,
string: '2026 ABC 07 XYZ 14',
typeName: 'PlainDate'
});
// This doesn't work! The individual characters don't match.
// throws ParseErrorStrict mode takes precedence over the FX modifier.
Time zone parsing
The time zone patterns parse names, abbreviations, and offsets. This goes beyond PostgreSQL's to_timestamp, which reads only numeric offsets.
TimeZone,TIMEZONE, andtimezoneaccept IANA identifiers in any case (America/New_York,america/new_york) and canonicalize them, offset identifiers (+05:30,-08), and abbreviations as a fallback.TZandtztry abbreviations first (PDT,est), thenGMT/UTC/UT/Z-relative offsets (GMT+5:30,UTC-8,Z), then identifiers.- Abbreviations resolve to fixed offsets, following PostgreSQL's default abbreviation list:
PDTis-07:00,ESTis-05:00,CSTis-06:00(US Central), and so on.ISTis deliberately omitted because it is ambiguous (India, Ireland, and Israel all claim it); use a name or an offset instead. If you need different abbreviation semantics, subclass and override the abbreviation table (see Custom Patterns). - A parsed IANA zone becomes the
timeZoneIdof aZonedDateTimeresult, so daylight saving is applied for the parsed wall-clock time. For anInstantresult, the zone is used to compute the exact time. If a wall-clock time is ambiguous or skipped at a daylight saving transition, it resolves withTemporal's default'compatible'disambiguation. - The parser reads the longest run of time zone characters that resolves to a zone, so a following literal is still matched:
TimeZone"X"readsUTCXas the zoneUTCfollowed by the literalX.
_TemporalFormat.fromString({
format: 'YYYY-MM-DD HH24:MI:SS TimeZone',
string: '2026-07-14 12:00:00 America/Los_Angeles',
typeName: 'ZonedDateTime'
});
// Temporal.ZonedDateTime <2026-07-14T12:00:00-07:00[America/Los_Angeles]>
_TemporalFormat.fromString({
format: 'YYYY-MM-DD HH24:MI:SS TZ',
string: '2026-07-14 12:00:00 PDT',
typeName: 'Instant'
});
// Temporal.Instant <2026-07-14T19:00:00Z>Parsing limitations
D,W,WW,CC, andQare consumed but do not affect the result. Weekday names are likewise read and then ignored, as in PostgreSQL.- For
InstantandZonedDateTime, the time zone defaults to UTC unless a time zone pattern or an offset (TZH,TZM, orOF) is present. A numeric offset produces a fixed-offset zone rather than a named IANA zone. OFreads offset minutes only when they are written after a colon (for example+05:30).- A time zone offset should sit immediately after the preceding pattern, as in ISO 8601 output (for example
HH24:MI:SSOF). In the default lenient mode, a separator written before the offset is skipped along with the offset's own leading sign, which would drop the sign of a negative offset. The same applies to offset-style values read by the name patterns (for exampleTimeZonereading+05:30). Keep the offset adjacent, use a named zone, or useFX, so the sign is read correctly. - When parsing a
Duration, the same numeric and fractional patterns apply with wider digit limits, and the small-year adjustment does not apply: digits are taken literally as a count.
Error Handling
The package throws errors created by isotropic-error.
ArgumentErroris thrown whenvalueis not a supportedTemporaltype, whenformatorstringis not a string, or whentypeNameis not a supported type name.CalendarErroris thrown when a value uses a non-ISO calendar (see Calendars).FormatErroris thrown when a pattern cannot apply to the value's type (see Type Validation).ParseErroris thrown when the input does not match the format, for example when digits, a month name, a meridiem indicator, an era indicator, a Roman numeral, or an offset are expected but not found.
import _TemporalFormat from 'isotropic-temporal-format';
try {
_TemporalFormat.fromString({
format: 'YYYY',
string: 'not a year',
typeName: 'PlainDate'
});
} catch (error) {
// error.name === 'ParseError'
}Custom Patterns
TemporalFormat is built with isotropic-make, so a subclass can register additional patterns by supplying its own _patternSet. The subclass inherits every built-in pattern; the parent class is not modified.
A pattern object pairs a set of lexemes with format and parse behavior. The base pattern constructors are importable directly: isotropic-temporal-format/lib/number-pattern.js for padded numbers, isotropic-temporal-format/lib/name-pattern.js for names, and so on. For a numeric pattern, configure:
lexemeSet: the template lexemes that select the pattern.width: the zero-padded output width.getValue: maps the value's deconstruction (an object with fields likedisplayYear,month,dayOfMonth,hour,subsecondNs) to the number to render.parseKey: the parsed-field name to assign when reading input (omit to consume and ignore, like PostgreSQL does withCC).requiredCapabilitySet: which value types the pattern applies to (_dateCapabilitySet,_timeCapabilitySet, and so on); anything else raises aFormatError.
This example adds a five-digit year pattern named KYEAR:
import _make from 'isotropic-make';
import _NumberPattern from 'isotropic-temporal-format/lib/number-pattern.js';
import _TemporalFormat from 'isotropic-temporal-format';
const _MyTemporalFormat = _make('MyTemporalFormat', _TemporalFormat, {}, {
_patternSet: new Set([
_NumberPattern({
getValue (deconstruction) {
return deconstruction.displayYear;
},
lexemeSet: new Set([
'KYEAR'
]),
parseKey: 'year',
requiredCapabilitySet: _NumberPattern._dateCapabilitySet,
width: 5
})
])
});
_MyTemporalFormat.toString({
format: 'KYEAR-MM-DD',
value: Temporal.PlainDate.from('2024-03-05')
});
// '02024-03-05'
_MyTemporalFormat.fromString({
format: 'KYEAR-MM-DD',
string: '02024-03-05',
typeName: 'PlainDate'
});
// Temporal.PlainDate <2024-03-05>Because lexing prefers longer lexemes, a custom lexeme can safely contain shorter built-in lexemes (like the Y in KYEAR); the longer match wins.
TemporalFormat uses isotropic-make's object-oriented inheritance patterns but the implementation is entirely static. TemporalFormat itself is a constructor function but there is no reason to ever create an instance of it.
Contributing
Please refer to CONTRIBUTING.md for information on how to contribute to this project.
Issues
If you encounter any issues, please file them at https://github.com/ibi-group/isotropic-temporal-format/issues
