@adaskothebeast/hierarchical-convert-to-temporal
v10.0.2
Published
**Deep, in-place conversion of ISO 8601 strings inside JSON payloads into `Temporal` values, plus a ready-made `Temporal` backend for the schema-driven typewriter runtime, part of [date-interceptors](https://github.com/AdaskoTheBeAsT/date-interceptors).**
Readme
⏳ @adaskothebeast/hierarchical-convert-to-temporal
Deep, in-place conversion of ISO 8601 strings inside JSON payloads into Temporal values, plus a ready-made Temporal backend for the schema-driven typewriter runtime, part of date-interceptors.
Peer dependencies: @js-temporal/polyfill ^0.5.1, @adaskothebeast/typewriter-runtime 10.0.0 (types only, used by the backend export) and tslib ^2.8.1. Version 10.0.0.
📦 Install
npm i @adaskothebeast/hierarchical-convert-to-temporal @js-temporal/polyfill @adaskothebeast/typewriter-runtime@js-temporal/polyfill is imported directly by this package (import { Temporal } from '@js-temporal/polyfill'), so it is required even on runtimes that already ship a native Temporal.
🎯 What it does
Give it a parsed JSON response and it walks every own enumerable property of every nested object and array, replacing matching strings with Temporal instances. Nothing is returned; the input graph is mutated in place.
| String shape | Becomes |
| --------------------------------------- | -------------------------- |
| 2023-07-17T23:06:00.000Z | Temporal.Instant |
| 2023-07-17T23:06:00.000+01:00 | Temporal.Instant |
| 2023-07-17T23:06:00 (no offset) | Temporal.PlainDateTime |
| P1Y2M4DT2H3M2S, -PT1H, PT0.5S | Temporal.Duration |
| anything else | left untouched |
Only three Temporal types are ever produced by the walker: Instant, PlainDateTime and Duration. Date-only, time-only, year-month, month-day and zoned ([Europe/Paris]) strings are not recognised, because a bare JSON string carries no hint about which of those you meant. When you need the full set, use the schema-driven path with temporalDateBackend (see below).
🧰 API
| Export | Signature | Notes |
| ------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| hierarchicalConvertToTemporal | (obj: unknown, depth?: number, visited?: WeakSet<object>) => void | Mutates obj in place, returns void. depth defaults to 0, visited defaults to a fresh WeakSet. |
| temporalDateBackend | DateBackend ({ name: 'temporal'; codecs: Record<DateSchemaKind, DateCodec> }) | Nine codecs for @adaskothebeast/typewriter-runtime. name is the literal string 'temporal'. |
depth and visited exist so the function can recurse into itself; you normally pass only the first argument. Passing a non-zero depth lowers the remaining budget (the walker bails out once depth > 100), and passing a pre-populated visited set makes those objects be skipped.
temporalDateBackend codecs
Each codec is a DateCodec with is(value), parse(wire) and serialize(value); serialize is always value.toString().
| Schema kind | Runtime type |
| ------------------ | --------------------------- |
| instant | Temporal.Instant |
| plain-date | Temporal.PlainDate |
| plain-time | Temporal.PlainTime |
| plain-date-time | Temporal.PlainDateTime |
| zoned-date-time | Temporal.ZonedDateTime |
| duration | Temporal.Duration |
| period | Temporal.Duration |
| plain-year-month | Temporal.PlainYearMonth |
| plain-month-day | Temporal.PlainMonthDay |
⚡ Usage
import { hierarchicalConvertToTemporal } from '@adaskothebeast/hierarchical-convert-to-temporal';
import { Temporal } from '@js-temporal/polyfill';
const payload = {
user: {
name: 'Adam',
createdAt: '2023-07-17T23:06:00.000Z',
localReminder: '2023-07-17T23:06:00',
sessions: [{ length: 'PT1H30M' }, { length: 'P0D' }],
},
};
hierarchicalConvertToTemporal(payload);
payload.user.createdAt instanceof Temporal.Instant; // true
payload.user.localReminder instanceof Temporal.PlainDateTime; // true
payload.user.sessions[0].length instanceof Temporal.Duration; // trueAs a fetch post-processing step:
async function getJson<T>(url: string): Promise<T> {
const response = await fetch(url);
const data = (await response.json()) as T;
hierarchicalConvertToTemporal(data);
return data;
}Schema-driven hydration, where the shape decides the Temporal type instead of a regex:
import { temporalDateBackend } from '@adaskothebeast/hierarchical-convert-to-temporal';
import { createJsonTransformer } from '@adaskothebeast/typewriter-runtime';
import { userSchema } from './generated/user.schema';
const toUser = createJsonTransformer(userSchema, undefined, {
dateBackend: temporalDateBackend,
});
const user = toUser(await response.json());dateBackend is read by transformJson / serializeJson (and their create* wrappers), which look up backend.codecs[kind] for the date kind declared in the schema and throw Date backend "temporal" does not support <kind> when a kind is missing.
🎛️ Options and configuration
There are no options: no key allow-list, no format list, no target-type selection. Recognition is driven purely by the string content, in this order:
- Date-time gate. Length at least 19,
-at index 4,-at index 7,Tat index 10, then the regex^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|([+-]\d{2}:\d{2}))?$. One to nine fractional digits are allowed, so nanosecond precision survives. - Instant or PlainDateTime. A trailing
Z, or a+/-six characters from the end, means an exact time and yieldsTemporal.Instant.from(value). OtherwiseTemporal.PlainDateTime.from(value). - Duration gate. Length at least 3, starts with
Por-P, then an ISO 8601 duration regex, yieldingTemporal.Duration.from(value). The regex also accepts a comma as the decimal separator (PT0,5S).
Recursion is guarded by a depth budget of 100 and a WeakSet of already visited objects.
📤 Output examples
const input = {
instant: '2023-07-17T23:06:00.000Z',
offset: '2023-07-17T23:06:00.000+01:00',
nanos: '2023-07-17T23:06:00.123456789Z',
local: '2023-07-17T23:06:00',
weeks: 'P4W',
negative: '-PT1H',
dateOnly: '2023-07-17',
broken: '2023-99-99T99:99:99.000Z',
};
hierarchicalConvertToTemporal(input);instant -> Temporal.Instant 2023-07-17T23:06:00Z
offset -> Temporal.Instant 2023-07-17T22:06:00Z (offset folded into the exact time)
nanos -> Temporal.Instant 2023-07-17T23:06:00.123456789Z
local -> Temporal.PlainDateTime 2023-07-17T23:06:00
weeks -> Temporal.Duration P4W
negative -> Temporal.Duration -PT1H
dateOnly -> '2023-07-17' (unchanged, no time part)
broken -> '2023-99-99T99:99:99.000Z' (unchanged, console.warn emitted)Top-level arrays work too: ['P1Y2M4DT2H3M2S'] becomes [Temporal.Duration.from('P1Y2M4DT2H3M2S')].
⚠️ Edge cases
- Prototype pollution is blocked. The keys
__proto__,constructorandprototypeare skipped entirely, so a hostile payload cannot reachObject.prototype. The side effect is that legitimate data parked under a key literally namedconstructororprototypeis never traversed or converted. - Inherited properties are ignored. Every key is checked with
Object.hasOwn, so only own enumerable properties are visited. - Depth is capped at 100. Once
depth > 100the walker returns, leaving deeper strings as strings. This is DoS protection against adversarially nested JSON. - Circular graphs are safe. A
WeakSetrecords visited objects, soinput.self = inputconverts once and does not loop. - Offset information is lost for exact times.
2023-07-17T23:06:00.000+01:00becomes aTemporal.Instant, which is UTC based. If you need the original offset or a time zone, usezoned-date-timethroughtemporalDateBackendinstead. - Offsetless date-times become
PlainDateTime, which has no time zone at all. Comparing aPlainDateTimewith anInstantthrows, so a payload mixing both shapes needs explicit conversion (toZonedDateTime) before comparison. - Malformed but regex-matching strings stay strings.
2023-99-99T99:99:99.000Zpasses the regex,Temporal.Instant.fromthrows, the error is caught and logged viaconsole.warn('Failed to parse date string: ...'). Duration parse failures logFailed to convert duration string: .... Palone is not a duration. The minimum length of 3 plus a lookahead requiring at least one digit rejectsP,PTand-P.- False positives are possible. Recognition is content driven, never key driven, so an ISO-looking value in a
note,labeloridfield is converted as well. The schema-driven@adaskothebeast/typewriter-runtimepath exists exactly for payloads where that matters. - The polyfill is mandatory. Values are
instanceofthe polyfill's classes, not any nativeTemporalglobal. Mixing polyfill instances with a nativeTemporalimplementation in the same process will failinstanceofchecks. - Name collision with the runtime.
@adaskothebeast/typewriter-runtimeexports its owntemporalDateBackendwith the same nine codecs. Importing both into one module needs an alias (import { temporalDateBackend as temporalBackend } from ...). Pick one; they are interchangeable. periodanddurationare the same codec pair.periodexists so schemas generated from ajava.time.Periodor a .NETTimeSpanstill resolve, but both map toTemporal.Duration.
🔗 Related packages
- Same traversal, other date libraries:
hierarchical-convert-to-date,-date-fns,-dayjs,-luxon,-moment,-js-joda - Other value kinds:
hierarchical-convert-to-decimal,hierarchical-convert-to-uuid - Transports:
angular-date-http-interceptor,axios-interceptor,react-redux-toolkit-hierarchical-date-hook - Schema stack:
typewriter-schema,typewriter-runtime,typewriter-http-angular,typewriter-http-axios,typewriter-http-fetch
Full matrix and adapter recipes: main README.
📄 License
MIT © Adam Pluciński
