@adaskothebeast/hierarchical-convert-to-luxon
v10.0.2
Published
**Luxon bindings for [date-interceptors](https://github.com/AdaskoTheBeAsT/date-interceptors): walk a parsed JSON payload and turn ISO 8601 strings into `DateTime` and `Duration` values, plus a schema-driven Luxon backend for the typewriter runtime.**
Readme
🌞 @adaskothebeast/hierarchical-convert-to-luxon
Luxon bindings for date-interceptors: walk a parsed JSON payload and turn ISO 8601 strings into DateTime and Duration values, plus a schema-driven Luxon backend for the typewriter runtime.
Peer dependencies: @adaskothebeast/typewriter-runtime (10.0.0), luxon (^3.7.2), tslib (^2.8.1). Published as CommonJS with .d.ts declarations, target ES2022.
📦 Install
npm i @adaskothebeast/hierarchical-convert-to-luxon luxon @adaskothebeast/typewriter-runtimeTypeScript users also need the community typings, because Luxon ships none of its own:
npm i -D @types/luxonNo time zone data package is required. Luxon reads IANA zones from the runtime Intl data, and IANAZone.isValidZone (used by the zoned codec) relies on it.
The runtime peer is only needed for luxonDateBackend. If you use nothing but hierarchicalConvertToLuxon, luxon alone is enough.
🎯 What it does
The package ships two independent pieces.
hierarchicalConvertToLuxon is the schema-less path. It walks an already-parsed JSON value depth first and replaces every string that looks like an ISO 8601 date-time or an ISO 8601 duration with a Luxon object, mutating the input in place and returning void. Nothing is cloned, so the object identity your caller holds stays the same. Traversal skips __proto__, constructor and prototype keys so a hostile payload cannot reach Object.prototype, uses Object.hasOwn so inherited enumerable properties are ignored, tracks visited objects in a WeakSet so circular graphs terminate, and gives up below a depth of 100.
luxonDateBackend is the schema-driven path. It is a plain object ({ name: 'luxon', codecs }) that satisfies the DateBackend contract from @adaskothebeast/typewriter-runtime, so you can hand it to transformJson, createJsonTransformer, serializeJson or createJsonSerializer as options.dateBackend and every date-ish schema node hydrates into Luxon instead of the default Temporal types.
🧰 API
| Export | Signature / shape | Notes |
| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| hierarchicalConvertToLuxon(obj: unknown, depth?: number, visited?: WeakSet): void | Mutates obj in place. depth defaults to 0, visited to a fresh WeakSet; both are recursion bookkeeping and you normally pass only obj. | Returns undefined. Non-objects and null are ignored. |
| luxonDateBackend | { readonly name: 'luxon'; readonly codecs: Record<DateSchemaKind, DateCodec> } | Declared with satisfies DateBackend, so it is a value, not a class. Nothing to instantiate. |
luxonDateBackend.codecs covers every DateSchemaKind:
| Kind | Luxon type | Wire form accepted by parse | serialize output |
| ------------------ | ---------- | ------------------------------------------------------------ | --------------------------------- |
| instant | DateTime | 2024-02-29T12:34:56.789+01:00, offset or Z required | value.toUTC().toISO() |
| plain-date | DateTime | 2024-02-29 | value.toISODate() |
| plain-time | DateTime | 12:34, 12:34:56, 12:34:56.789 | toISOTime({ includeOffset: false }) |
| plain-date-time | DateTime | 2024-02-29T12:34:56.789 (no offset) | toISO({ includeOffset: false }) |
| zoned-date-time | DateTime | 2024-07-01T12:34:56.789+02:00[Europe/Paris] | same bracketed form |
| duration | Duration | any ISO duration Luxon accepts, including -P1Y2M3DT4H5M6.789S | value.toISO() |
| period | Duration | identical to duration (the same codec object is reused) | value.toISO() |
| plain-year-month | DateTime | 2024-02 | toFormat('yyyy-MM') |
| plain-month-day | DateTime | --02-29 | toFormat("'--'MM-dd") |
Every codec exposes is, parse and serialize. parse throws RangeError on anything it cannot represent; is returns true only for a Luxon value whose isValid is true.
⚡ Usage
Schema-less, over an Axios response:
import { AxiosInstanceManager } from '@adaskothebeast/axios-interceptor';
import { hierarchicalConvertToLuxon } from '@adaskothebeast/hierarchical-convert-to-luxon';
const api = AxiosInstanceManager.createInstance(hierarchicalConvertToLuxon);
const { data } = await api.get('/orders/42');
// data.createdAt is a luxon DateTime, data.slaWindow is a luxon DurationSchema-less, over an Angular HttpClient response:
import { HIERARCHICAL_DATE_ADJUST_FUNCTION } from '@adaskothebeast/angular-date-http-interceptor';
import { hierarchicalConvertToLuxon } from '@adaskothebeast/hierarchical-convert-to-luxon';
providers: [
{ provide: HIERARCHICAL_DATE_ADJUST_FUNCTION, useValue: hierarchicalConvertToLuxon },
];Direct call on any parsed payload:
import { hierarchicalConvertToLuxon } from '@adaskothebeast/hierarchical-convert-to-luxon';
const payload = JSON.parse(body) as unknown;
hierarchicalConvertToLuxon(payload);Schema-driven, through the typewriter runtime:
import { luxonDateBackend } from '@adaskothebeast/hierarchical-convert-to-luxon';
import { schema } from '@adaskothebeast/typewriter-schema';
import { createJsonTransformer } from '@adaskothebeast/typewriter-runtime';
import type { DateTime, Duration } from 'luxon';
const orderSchema = schema.object<{ createdAt: DateTime; slaWindow: Duration }>({
createdAt: schema.instant(),
slaWindow: schema.duration(),
});
const toOrder = createJsonTransformer(orderSchema, undefined, {
mode: 'strict',
dateBackend: luxonDateBackend,
});
const order = toOrder({ createdAt: '2024-02-29T12:34:56.789+01:00', slaWindow: 'PT1H30M' });
// order.createdAt is a UTC DateTime, order.slaWindow is a Duration🎛️ Options and configuration
hierarchicalConvertToLuxon has no options. The depth and visited parameters exist for the recursive calls; passing your own visited set lets you share cycle tracking across several payloads, and passing a depth above 100 makes the call a no-op.
luxonDateBackend has no options either. It is a stateless singleton value and is safe to share between transformers. Everything else is decided by the runtime:
mode: 'strict'makes an unparsable value throwJsonTransformationError; the default tolerant mode leaves the raw string in place.maxDepth(runtime option, default100) caps schema recursion independently of the traversal cap above.- Omit
dateBackendand the runtime falls back to its built-intemporalDateBackend.
📤 Output examples
hierarchicalConvertToLuxon:
| Input value | Result |
| ------------------------------ | ----------------------------------------------------------- |
| '2023-07-17T23:06:00.000Z' | DateTime.fromISO('2023-07-17T23:06:00.000Z') (local zone) |
| '2023-07-17T23:06:00.000+01:00' | DateTime.fromISO('2023-07-17T23:06:00.000+01:00') |
| '2023-07-17T23:06:00' | unchanged string (19 characters, below the length gate) |
| 'P0D' | Duration.fromObject({ days: 0 }) |
| 'P4W' | Duration.fromObject({ weeks: 4 }) |
| 'P1Y2M4DT2H3M2S' | Duration.fromObject({ years: 1, months: 2, days: 4, hours: 2, minutes: 3, seconds: 2 }) |
| 'adam' | unchanged |
in : { someNewObj: { text: 'adam', date: '2023-07-17T23:06:00.000Z' } }
out: { someNewObj: { text: 'adam', date: DateTime } } // same object, mutatedluxonDateBackend:
instant '2024-02-29T12:34:56.789+01:00' -> serialize -> '2024-02-29T11:34:56.789Z'
plain-time '12:34:56.789' -> DateTime on 2000-01-01 -> '12:34:56.789'
zoned-date-time '2024-07-01T12:34:56.789+02:00[Europe/Paris]' round-trips verbatim
duration 'P1DT2H' -> Duration { days: 1, hours: 2 }⚠️ Edge cases
- In-place mutation. The traversal rewrites your object graph and returns nothing. Clone first (
structuredClone, but note it cannot clone the Luxon objects afterwards) if the caller must keep the raw strings. - Prototype-pollution keys are skipped.
__proto__,constructorandprototypeare never read or written, and only own properties (Object.hasOwn) are visited. - Depth cap of 100. Once
depth > 100, the branch is returned untouched with no error, so extremely deep payloads are silently left partly unconverted. - Cycles are visited once. The shared
WeakSetmeans a repeated object reference is skipped on the second encounter, so a node reachable through two paths is converted exactly once (which is fine, since conversion is idempotent per node). - Date strings must be at least 20 characters and have
-,-,Tat indices 4, 7, 10.2023-07-17T23:06:00(no offset, 19 characters) therefore stays a string even though the regex would allow it, and so does2023-07-17T23:06. - Fractional seconds must be exactly three digits.
2024-01-01T00:00:00.1Zand...000000Zdo not match, so they are left as strings. - The traversal duration regex is stricter than Luxon. It accepts only unsigned integer components (
P…Y M W D T H M S), soPT1.5S,-P1DandPT1,5Sare not converted byhierarchicalConvertToLuxon, whileluxonDateBackend.codecs.durationhappily parses all of them. - Invalid values stay strings. A matched date is assigned only when
DateTime.isValid, a matched duration only whenDuration.isValid. A string that matched the date shape returns early, so it is never retried as a duration. - Zones collapse to the system zone. The traversal calls
DateTime.fromISO(v)with no options, so an offset in the payload is honoured for the instant but the resultingDateTimeis in the local zone. Call.setZone('utc')yourself if you need UTC, or useluxonDateBackend.codecs.instant, which parses withsetZone: trueand then normalizes withtoUTC(). zoned-date-timeis strict about the zone.parserequires the bracketed…±HH:MM[Zone]form, rejects a zone thatIANAZone.isValidZonedoes not know, and rejects a payload whose offset disagrees with the zone at that instant (2024-07-01T12:34:56+01:00[Europe/Paris]throwsRangeError).serializethrows when theDateTimecarries a fixed offset or a local zone instead of a named IANA zone.- Codec
iscannot tell oneDateTimekind from another. All eightDateTime-based codecs share the sameDateTime.isDateTime && isValidguard, so an already-hydrated value passes through whichever kind the schema declares, and aplain-datenode will accept aDateTimethat also carries a time. durationandperiodare the same codec. Luxon has oneDurationtype, so aperiodschema node yields aDurationand a calendar-onlyP1Y2M3Dand a time-onlyPT4Hare equally valid for both kinds.plain-timevalues are anchored to 2000-01-01 UTC andplain-month-dayto the year 2000, which is why--02-29round-trips (2000 was a leap year) while--02-30throws.- Serialization can also fail: Luxon returns
nullfromtoISO()for values it cannot render, and the backend converts that intoRangeError: Unable to serialize ….
🔗 Related packages
- Same job, other libraries:
hierarchical-convert-to-date,-date-fns,-dayjs,-moment,-js-joda,-temporal - Other value kinds:
-decimal,-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
