@adaskothebeast/hierarchical-convert-to-js-joda
v10.0.2
Published
**js-joda bindings for [date-interceptors](https://github.com/AdaskoTheBeAsT/date-interceptors): walk a parsed JSON payload and turn ISO 8601 date-times into `ZonedDateTime` values, plus a schema-driven js-joda backend that covers every date kind with its
Downloads
435
Readme
🕰️ @adaskothebeast/hierarchical-convert-to-js-joda
js-joda bindings for date-interceptors: walk a parsed JSON payload and turn ISO 8601 date-times into ZonedDateTime values, plus a schema-driven js-joda backend that covers every date kind with its own dedicated type.
Peer dependencies: @adaskothebeast/typewriter-runtime (10.0.0), @js-joda/core (^6.1.0), @js-joda/timezone (^2.25.2), tslib (^2.8.1). Published as CommonJS with .d.ts declarations, target ES2022.
📦 Install
npm i @adaskothebeast/hierarchical-convert-to-js-joda @js-joda/core @js-joda/timezone @adaskothebeast/typewriter-runtime@js-joda/timezone is not optional: both modules in this package require('@js-joda/timezone') at load time so that named IANA zones such as [Europe/Paris] resolve. That is a load-time side effect, so a bundler must not treat these modules as side-effect free, and the zone database ends up in your bundle.
js-joda ships its own typings, so no @types/* package is needed.
🎯 What it does
The package ships two independent pieces.
hierarchicalConvertToJsJoda 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 with a ZonedDateTime, mutating the input in place and returning void. Nothing is cloned. Unlike the Luxon and Moment siblings it converts dates only; ISO duration strings such as P1Y2M4D are left untouched, because js-joda splits that space into Duration and Period and the traversal has no way to pick one. 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.
jsJodaDateBackend is the schema-driven path. It is a plain object ({ name: 'js-joda', codecs }) that satisfies the DateBackend contract from @adaskothebeast/typewriter-runtime, so you can hand it to transformJson, createJsonTransformer, serializeJson or createJsonSerializer as options.dateBackend. It is the most faithful backend in the workspace: every schema kind maps to a distinct js-joda class, so instant, plain-date and period can never be confused with each other, and nanosecond precision survives a round trip.
🧰 API
| Export | Signature / shape | Notes |
| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| hierarchicalConvertToJsJoda(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. Dates only, no durations. |
| jsJodaDateBackend | { readonly name: 'js-joda'; readonly codecs: Record<DateSchemaKind, DateCodec> } | Declared with satisfies DateBackend, so it is a value, not a class. Nothing to instantiate. |
jsJodaDateBackend.codecs covers every DateSchemaKind, and every serialize is simply value.toString():
| Kind | js-joda type | parse | Example wire value |
| ------------------ | --------------- | ---------------------- | --------------------------------------------- |
| instant | Instant | Instant.parse | 2024-01-02T03:04:05.123456789Z |
| plain-date | LocalDate | LocalDate.parse | 2024-07-21 |
| plain-time | LocalTime | LocalTime.parse | 12:34:56.123456789 |
| plain-date-time | LocalDateTime | LocalDateTime.parse | 2024-07-21T12:34:56.123456789 |
| zoned-date-time | ZonedDateTime | ZonedDateTime.parse | 2024-01-02T03:04:05+01:00[Europe/Paris] |
| duration | Duration | Duration.parse | PT26H3M4.005S |
| period | Period | Period.parse | P1Y2M3D |
| plain-year-month | YearMonth | YearMonth.parse | 2024-07 |
| plain-month-day | MonthDay | MonthDay.parse | --07-21 |
is is an instanceof check against the matching class, so it is exact for all nine kinds.
⚡ Usage
Schema-less, over an Axios response:
import { AxiosInstanceManager } from '@adaskothebeast/axios-interceptor';
import { hierarchicalConvertToJsJoda } from '@adaskothebeast/hierarchical-convert-to-js-joda';
const api = AxiosInstanceManager.createInstance(hierarchicalConvertToJsJoda);
const { data } = await api.get('/orders/42');
// data.createdAt is a ZonedDateTime; data.slaWindow is still the raw 'PT1H30M' stringSchema-less, over an Angular HttpClient response:
import { HIERARCHICAL_DATE_ADJUST_FUNCTION } from '@adaskothebeast/angular-date-http-interceptor';
import { hierarchicalConvertToJsJoda } from '@adaskothebeast/hierarchical-convert-to-js-joda';
providers: [
{ provide: HIERARCHICAL_DATE_ADJUST_FUNCTION, useValue: hierarchicalConvertToJsJoda },
];Direct call on any parsed payload:
import { hierarchicalConvertToJsJoda } from '@adaskothebeast/hierarchical-convert-to-js-joda';
const payload = JSON.parse(body) as unknown;
hierarchicalConvertToJsJoda(payload);Schema-driven, through the typewriter runtime (this is the only way to get durations, periods and plain types):
import { jsJodaDateBackend } from '@adaskothebeast/hierarchical-convert-to-js-joda';
import { schema } from '@adaskothebeast/typewriter-schema';
import { createJsonTransformer } from '@adaskothebeast/typewriter-runtime';
import type { Duration, Instant, LocalDate, Period } from '@js-joda/core';
const orderSchema = schema.object<{
createdAt: Instant;
dueOn: LocalDate;
slaWindow: Duration;
retention: Period;
}>({
createdAt: schema.instant(),
dueOn: schema.plainDate(),
slaWindow: schema.duration(),
retention: schema.period(),
});
const toOrder = createJsonTransformer(orderSchema, undefined, {
mode: 'strict',
dateBackend: jsJodaDateBackend,
});
const order = toOrder({
createdAt: '2024-01-02T03:04:05.123456789Z',
dueOn: '2024-07-21',
slaWindow: 'PT1H30M',
retention: 'P1Y2M3D',
});🎛️ Options and configuration
hierarchicalConvertToJsJoda 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.
jsJodaDateBackend has no options either. It is a singleton value and is safe to share between transformers. Everything else is decided by the runtime:
mode: 'strict'makes an unparsable value throwJsonTransformationError(with the js-joda parse error as itscause); 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
hierarchicalConvertToJsJoda:
| Input value | Result |
| --------------------------------- | ------------------------------------------------------------------- |
| '2023-07-17T23:06:00.000Z' | ZonedDateTime.parse('2023-07-17T23:06:00.000Z') (zone Z) |
| '2023-07-17T23:06:00.000+01:00' | ZonedDateTime.parse('2023-07-17T23:06:00.000+01:00') (offset kept) |
| '2023-07-17T23:06:00' | unchanged string (19 characters, below the length gate) |
| 'P1Y2M4DT2H3M2S' | unchanged string (durations are never converted here) |
| 'adam' | unchanged |
in : { someNewObj: { text: 'adam', date: '2023-07-17T23:06:00.000Z' } }
out: { someNewObj: { text: 'adam', date: ZonedDateTime } } // same object, mutatedjsJodaDateBackend round trips (all asserted by the unit tests):
instant '2024-01-02T03:04:05.123456789Z' -> Instant -> identical string
plain-time '12:34:56.123456789' -> LocalTime -> identical string
zoned-date-time '2024-01-02T03:04:05+01:00[Europe/Paris]' -> ZonedDateTime -> identical string
duration 'PT26H3M4.005S' -> Duration -> identical string
period 'P1Y2M3D' -> Period -> identical string
plain-month-day '--07-21' -> MonthDay -> identical string⚠️ Edge cases
- In-place mutation. The traversal rewrites your object graph and returns nothing. Clone the payload first 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. - No duration or period conversion in the traversal. There is no duration regex in
hierarchicalConvertToJsJodaat all, soPT1H,P1Y2M3Dand friends stay strings. Convert them yourself withDuration.parse/Period.parse, or usejsJodaDateBackendwith a schema, which distinguishes the two. - Date strings must be at least 20 characters and have
-,-,Tat indices 4, 7, 10.2023-07-17T23:06:00(19 characters) therefore stays a string, which also happens to match js-joda's own requirement that aZonedDateTimecarry an offset. Fractional seconds must be exactly three digits, so…T23:06:00.123456789Zis not picked up by the traversal even thoughZonedDateTime.parsewould accept it. - Parse failures are swallowed.
ZonedDateTime.parsethrowing is caught, logged withconsole.warn('Failed to parse date string: …'), and the original string is kept. Nothing is thrown out of the traversal. - Offsets are preserved, not normalized.
ZonedDateTime.parse('…+01:00')keeps a fixedZoneOffsetand…Zkeeps zoneZ, so the wire representation survives verbatim. This differs from the Luxon and Moment traversals, which reinterpret the instant in the local or UTC zone. Nothing in the traversal converts an offset into a named IANA zone; only a bracketed[Region/City]payload produces one, and only through thezoned-date-timecodec. - Codec errors are js-joda errors. The backend does no pre-validation with regexes, so
parsesurfacesDateTimeParseException(andDateTimeExceptionfor impossible values) rather than theRangeErrorthe Luxon and Moment backends raise. In strict mode the runtime wraps it inJsonTransformationError. durationandperiodare not interchangeable.Duration.parseaccepts time-based amounts only (PT…, plus a day component), andPeriod.parseaccepts date-based amounts only (P1Y2M3D). SendingP1Yto adurationnode orPT1Hto aperiodnode throws.- Nanosecond precision is real for
Instant,LocalTime,LocalDateTimeandZonedDateTime. Values that round-trip here lose precision the moment you hand them to aDate, Luxon or Moment based backend. instantis notzoned-date-time. Becauseisusesinstanceof, anInstantfails azoned-date-timenode and aZonedDateTimefails aninstantnode; useatZone/toInstantto move between them yourself.- Named zones need the loaded zone database. Both modules
require('@js-joda/timezone')themselves, so this works out of the box, but a bundler configured to tree-shake that require will make[Europe/Paris]throw at parse time.
🔗 Related packages
- Same job, other libraries:
hierarchical-convert-to-date,-date-fns,-dayjs,-luxon,-moment,-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
