@adaskothebeast/hierarchical-convert-to-date
v10.0.2
Published
**The zero-dependency core of [date-interceptors](https://github.com/AdaskoTheBeAsT/date-interceptors): walks a parsed JSON graph and replaces ISO 8601 date strings with native `Date` instances in place.**
Downloads
816
Readme
📅 @adaskothebeast/hierarchical-convert-to-date
The zero-dependency core of date-interceptors: walks a parsed JSON graph and replaces ISO 8601 date strings with native Date instances in place.
Only peer dependency is tslib (^2.8.1); no date library, no framework. Built with tsc to CommonJS plus .d.ts declarations.
📦 Install
npm i @adaskothebeast/hierarchical-convert-to-date🎯 What it does
JSON.parse gives you strings where your API meant instants. This package fixes that after the fact: it walks every own enumerable property of every nested object and array, recognizes ISO 8601 date strings, and assigns a Date back into the same slot.
const payload = { createdAt: '2023-07-17T23:06:00.000Z' };
hierarchicalConvertToDate(payload);
payload.createdAt instanceof Date; // trueThe traversal is deliberately defensive: __proto__, constructor and prototype keys are never written to, inherited properties are skipped via Object.hasOwn, already visited objects are tracked in a WeakSet so cycles terminate, and recursion stops past a fixed depth. That makes it safe to point at untrusted response bodies.
fetchJson is a thin convenience wrapper: one fetch call, status handling, response.json(), then the conversion.
🧰 API
Conversion
| Symbol | Signature | Notes |
| --------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| hierarchicalConvertToDate(obj, depth?, visited?) | (obj: unknown, depth?: number, visited?: WeakSet<object>) => void | Mutates obj and returns nothing. depth defaults to 0, visited to a fresh WeakSet |
Both optional parameters exist for the recursive calls, but they are usable: pass a higher depth to shrink the remaining budget, or share a visited set across several calls so an object graph is only walked once.
Fetch helper
| Symbol | Signature | Notes |
| ----------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| fetchJson<T>(input, init?, options?) | (input: RequestInfo \| URL, init?: RequestInit, options?: FetchJsonOptions) => Promise<T> | Resolves with converted, typed data |
| FetchJsonOptions | { readonly fetch?: typeof globalThis.fetch } | Inject a custom fetch (tests, polyfills, instrumented client) |
| FetchJsonError | class FetchJsonError extends Error | name: 'FetchJsonError', plus readonly status and statusText |
FetchJsonError is constructed as new FetchJsonError(status, statusText) and its message reads HTTP request failed with status 503 Service Unavailable.
⚡ Usage
Convert a payload you already have:
import { hierarchicalConvertToDate } from '@adaskothebeast/hierarchical-convert-to-date';
interface Order {
id: string;
createdAt: Date;
lines: { shippedAt: Date | null }[];
}
const raw: unknown = JSON.parse(text);
hierarchicalConvertToDate(raw);
const order = raw as Order;Fetch and convert in one step:
import { FetchJsonError, fetchJson } from '@adaskothebeast/hierarchical-convert-to-date';
try {
const order = await fetchJson<Order>('/api/orders/1');
console.log(order.createdAt.getFullYear());
} catch (e) {
if (e instanceof FetchJsonError) {
console.error(e.status, e.statusText);
}
}Inject a fetch implementation:
await fetchJson<Order>('/api/orders/1', { method: 'GET' }, { fetch: myInstrumentedFetch });🎛️ Options and configuration
There is nothing to configure globally; behaviour is fixed by design so the hot path stays cheap.
Recognized shape. A string is only considered when it is at least 20 characters long and has - at index 4, - at index 7 and T at index 10. Those four checks run before any regular expression. Survivors must then match:
^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?(Z|([+-]\d{2}:\d{2}))?$So milliseconds are optional but must be exactly three digits, and the zone is optional (Z or ±HH:MM).
Depth budget. The guard is depth > 100, so the root object plus 100 nested levels are walked and anything deeper is left as strings.
Parsing is done by the platform: new Date(value), kept only when getTime() is not NaN.
📤 Output examples
| Input | After conversion |
| ---------------------------------------------------- | --------------------------------------------------------- |
| { date: '2023-07-17T23:06:00.000Z' } | { date: Date('2023-07-17T23:06:00.000Z') } |
| { date: '2023-07-17T23:06:00.000+01:00' } | { date: Date('2023-07-17T23:06:00.000+01:00') } |
| { nested: { date: '2023-07-17T23:06:00.000Z' } } | { nested: { date: Date(...) } } |
| ['2023-07-17T23:06:00.000Z'] | [Date(...)] |
| [{ date: '...Z' }, { date: '...Z' }] | both elements converted |
| { text: 'adam', number: 42, flag: true, n: null } | untouched |
| { d: '2023/07/17 23:06:00' } | untouched (wrong format) |
| { d: 'Monday, July 17, 2023' } | untouched |
| { d: '2023-99-99T99:99:99.000Z' } | untouched (matched the pattern, parsed to an invalid date) |
// fetchJson
200 + body -> resolved value with Date instances in place of ISO strings
204 -> resolves to undefined
503 -> rejects with FetchJsonError { status: 503, statusText: 'Service Unavailable' }⚠️ Edge cases
- Mutation in place. The function returns
voidand rewrites your object. Clone first (structuredClone) if the caller needs the original strings.fetchJsonmutates the value it just parsed, which nobody else holds. - Prototype pollution is blocked.
__proto__,constructorandprototypekeys are skipped entirely, so a payload carrying them cannot reachObject.prototypethrough this traversal. Non-own (inherited) enumerable properties are skipped too. - Cycles are safe. Visited objects go into a
WeakSet, soinput.self = inputorinput.nested.circular = inputcompletes without throwing. The same object shared in two branches is therefore only walked once, which is harmless because conversion happens in place. - Past depth 100 nothing changes. Dates nested deeper than 101 object levels stay strings; no error, no warning.
- Invalid dates stay strings.
2023-99-99T99:99:99.000Zmatches the pattern butnew DateyieldsNaN, so the original string is preserved. You never get anInvalid Dateobject out of this package. - The 20 character floor rejects
2023-07-17T23:06:00. It is 19 characters, so a second-precision timestamp with no zone and no milliseconds is not converted even though the regular expression would accept it.2023-07-17T23:06:00Z(20) and2023-07-17T23:06:00.000(23) both are. - Offset-less values are local time, because that is what
new Date('2023-07-17T23:06:00.000')does for date-time forms. Values withZor an explicit offset are exact instants. Nothing here normalizes to UTC. - Date-only strings are never touched.
2023-07-17fails the length check, which avoids the classic "plain date silently became midnight UTC" bug. - Durations are not handled.
P1Y2M3Dstays a string; use the-date-fns,-dayjs,-luxon,-moment,-js-jodaor-temporalpackages if your payload carries ISO durations. - Values inside
MapandSetare invisible, sincefor...insees no own enumerable entries on them.Dateinstances already present are traversed harmlessly and left alone. hierarchicalConvertToDateon a primitive,nullorundefinedis a no-op, so it is safe to call on anyunknown.fetchJsononly special-cases 204. A200with an empty body rejects with theSyntaxErrorfromresponse.json(). Any non-okresponse rejects withFetchJsonErrorbefore the body is read.
🔗 Related packages
- Same traversal, other date libraries:
-date-fns,-dayjs,-luxon,-moment,-js-joda,-temporal - Other value kinds:
-decimal,-uuid - Transports:
angular-date-http-interceptor,axios-interceptor,react-redux-toolkit-hierarchical-date-hook - Schema-driven alternative:
typewriter-schema,typewriter-runtime
Full matrix and recipes: main README.
📄 License
MIT © Adam Pluciński
