Skip to main content
Version: v0.1.0

Spec notes: datetime precision + timezone fidelity

Spec: HL7 v2 Ch. 2A DTM: YYYY[MM[DD[HH[MM[SS[.S[S[S[S]]]]]]]]][+/-ZZZZ]; the number of characters populated (excluding the offset) sets the precision; a missing offset "defaults to that of the local time zone of the sender" (NOT UTC, NOT the parser's zone). TS→DTM moved precision from an explicit degree-of-precision component to the value's length (v2.5/v2.7).

Why there is no Date by default​

A JavaScript Date is an absolute instant, and most HL7 v2 timestamps are not. |1970| is a year, |19880705| is a calendar day, and a value with no offset is the sender's local time by the standard's own words. Materializing either as a Date means inventing a zone, and inventing UTC is how a day-only date of birth |19880705| becomes a UTC-midnight instant that reads back as July 4 through .getDate() in any negative-offset zone. So this library parses DTM into typed parts, keeps the precision it was given, and builds a Date only when the caller explicitly asks for one.

The API​

Core: src/parser/dates.ts​

  • type DtmPrecision = "year"|"month"|"day"|"hour"|"minute"|"second"|"fraction".
  • interface DtmParts { raw; valid; precision?; year?; month?(1–12, spec-native); day?; hour?; minute?; second?; fractionalSeconds?(verbatim digits, no dot); hasTimezone; offsetMinutes?(signed, iff tz); matchedFormat?(a declared or built-in format matched); ambiguity?(order refused, see below) }.
  • parseDtm(raw): DtmParts: pure structural parse of the HL7 DTM shape. No zero-fill, no Date, no UTC. Precision from populated length. Calendar-range check (month 1–12, day 1–31, hour 0–23, min/sec 0–59, offset hours ≤ 24 with minutes ≤ 59) → on bad shape/range valid:false, raw kept, parts omitted, never a throw. Empty → valid:false, hasTimezone:false.
  • formatDtm(parts): string: reconstruct the DTM string from parts (proves losslessness; round-trip property = parseDtm(raw)→formatDtm ≡ raw, exact length, no zero-fill).
  • dtmToDate(parts, opts?: { assumeOffsetMinutes?: number }): Date | undefined: explicit, opt-in absolute-instant materialization. !valid→undefined. Truncated fields fill to lowest legal value for instant construction only (precision still tells the truth). hasTimezone→exact instant via the embedded offset. else assumeOffsetMinutes→apply it. else→undefined (refuse to guess; never silent UTC).
  • parseDtmDeclared(raw, formats): DtmParts: the TS composite path's parse: try parseDtm; else the formats the CALLER declared, in order → parts from the matched tokens + matchedFormat. Stops there. No built-in fallback, deliberately (see below).
  • parseDtmCascade(raw, opts): DtmParts: lenient wrapper for non-composite callers (meta): parseDtmDeclared first, else BUILTIN_DATE_FALLBACKS, whose membership and order are unchanged: ISO-8601, YYYY-MM-DD, MM/DD/YYYY, MM/DD/YYYY HH:mm:ss. What a declared format may contain is SUPPORTED_DATE_TOKENS, stated in full by the Date token grammar. One exception, below: an order-ambiguous slash date resolves to nothing.

Order ambiguity: 05/07/1988 is refused, not guessed​

BUILTIN_DATE_FALLBACKS carries MM/DD/YYYY and not DD/MM/YYYY. A day-first sender's 05/07/1988 therefore used to read as May 7, with no failure and no signal that the field order had been assumed. In a PHI-bearing library that is the worst shape of wrong: plausible, confident and invisible.

A slash-separated numeric date whose first two components are both in 1-12 has two legal readings. When no declared format has matched, the built-ins now resolve neither: the result is valid: false with an ambiguity report (code: AMBIGUOUS_DATE_ORDER, the raw value, and both candidates as { format, month, day, isoDate }). msg.meta.timestamp carries it, so ambiguity distinguishes "refused" from "malformed" (no timestamp at all) and from "resolved via a fallback" (valid: true + matchedFormat).

  • Declared formats still win. parseHL7(raw, { dateFormats: ["DD/MM/YYYY"] }) and a profile's dateFormats are tried ahead of the built-ins, resolve the value, keep the existing TIMESTAMP_FALLBACK_FORMAT semantics, and never report ambiguity. Options precede profile.
  • One reading still resolves. 07/25/1988 (no month 25) and 05/05/1988 (both readings agree) are unaffected, as are strict DTM, ISO-8601 and YYYY-MM-DD.
  • The built-in list did not change. DD/MM/YYYY is used only as a probe for the second reading and never resolves a value: an unambiguous day-first value such as 25/07/1988 still fails loudly. Declaring the order is the route for a day-first feed.
  • No warning code was added or renamed. Hl7Message.warnings is frozen at construction and msg.meta is built lazily afterwards, so the report travels on the value rather than the warnings collection.
  • A typed datetime field never reaches this at all. The built-ins are the only stage parseDtmDeclared omits, so patient.dateOfBirth and every other typed datetime answer a day-first 05/07/1988 with a plain valid: false and no ambiguity report: nothing consulted a built-in, so there was no second reading to refuse. msg.meta.timestamp is the value that carries the report.

Conversion surface: src/parser/date-conversion.ts​

A read layer over DtmParts, carrying the three names every @cosyte parser exposes. It converts; it never re-parses, and it changes nothing about how a value was parsed.

  • interface DateParts { year?; month?(1-12, spec-native); day?; hour?; minute?; second?; millisecond?; offsetMinutes?(signed, iff an explicit offset was stated) }. Frozen. Names singular.
  • toObject(value): DateParts | undefined: only the stated components, so Object.keys() recovers the precision. A component the value did not state is absent, not present-with-undefined. No raw, valid, precision, hasTimezone or matchedFormat key. millisecond is the first three fractional digits verbatim, right-padded (.5=500, .0500=50), never a float multiplication. -0000 surfaces as offsetMinutes: 0. !valid or no stated component at all -> undefined.
  • toISO(value): string | undefined: ISO-8601 truncated to the stated precision, fractional digits verbatim. Offset appended as Z when exactly zero (-0000 included), else +HH:MM/ -HH:MM; nothing appended when no offset was stated. So toISO is a rendering and formatDtm remains the byte-exact round trip. No year (which HL7 DTM cannot state) -> undefined.
  • toDate(value, opts?): Date | undefined: delegates to dtmToDate, so the zone rule and the sub-100-year handling are the existing ones, unchanged.
  • All three accept undefined / null and return undefined. None ever throws, for any input.
  • Options bounds, on the second parameter as well as the first: the bag may be null or absent (opts?. is read, not defaulted, because a default parameter fires for undefined only), and assumeOffsetMinutes must be a finite number or the answer is undefined. "0", true and [] all multiply to 0 in JS, so an unguarded delegation hands back a UTC instant the caller never asked for; NaN, the infinities and an offset past the range a Date represents are refused for the same reason, so no answer is ever an Invalid Date. A value stating its own offset never reaches the guard: its offset wins and the assumption is ignored, per the Contract. dtmToDate is a published name and keeps its own coercing behaviour: the guard is added by the conversion layer, exactly as the component bound below is.
  • Component bounds, applied by all three to the WHOLE value: year 0-9999, month 1-12, day 1 to the last day THAT month has (full 4/100/400 leap rule; an unstated year bounds February at 29), hour 0-23, minute 0-59, second 0-59. 20240230 and 20230229 convert to undefined rather than rolling into 1 March, and an in-range prefix is never converted in place of the refused value. The bound reads the components, so a hand-built DtmParts is held to it exactly as a parsed one is. parseDtm is untouched and stays liberal (day 1-31, month-blind), so formatDtm still round-trips those bytes: the refusal is in the conversion, which is where a wrong answer would look right. new Date("2024-02-30") is 1 March in V8, so rendering it would shift a date of birth by a day with nothing to notice.
  • The stated offset is a bounded component too, when the value claims one: a whole number of minutes within +/-23:59 of UTC, which is both what the +HH:MM rendering can state and what an ISO-8601 reader accepts (new Date("2024-02-29T12:00:00+24:00") is an Invalid Date). The same helper does it, so a string, a boolean, an array, NaN and a fractional minute are refused with it. Two routes reach that field without a cast: a hand-built DtmParts, and msg.meta.timestamp, whose fallback read range-checks the calendar components and not the offset, so a wire MSH-7 of 2024-02-29T12:00:00+99:99 states 6039 minutes east. Unbounded, toObject reports a non-number in a number slot, toISO renders +NaN:NaN or a three-digit hour, and toDate multiplies a null or a "0" to zero and answers a confident UTC instant. parseDtm (up to +2400), formatDtm and dtmToDate are untouched, exactly as for the calendar bound above. An offsetMinutes on a value with hasTimezone: false states nothing and is ignored, not refused.
  • The three names are identical across the @cosyte parsers, so a consumer importing two of them aliases (import { toISO as hl7ToISO } from "@cosyte/hl7") or namespace-imports.

TS composite: src/model/types/ts.ts​

  • TS is the DtmParts shape (raw, valid, precision?, parts, hasTimezone, offsetMinutes?). Frozen. No .date. parseTs(rep, enc, dateFormats?) = unescape → parseDtmDeclared.
  • field.ts::asTs() returns it, passing the message's merged dateFormats.

Which datetimes honour dateFormats​

ParseOptions.dateFormats ++ the applied profile's, deduped first-occurrence-wins, is msg.dateFormats, and every datetime below honours it: meta.timestamp, patient.dateOfBirth, visit.admit/dischargeDateTime, observations.observedDateTime + the TS|DT TypedValue.value, allergies.onsetDate, diagnoses.dateTime, insurance.effectiveDate / expirationDate, immunizations.administeredDateTime / expirationDate, charges.transactionDate, documents.activityDateTime, order/medication timings.start/endDateTime (TQ1 and legacy embedded TQ), and appointments.start/endDateTime.

Built-in fallbacks stop at meta.timestamp. BUILTIN_DATE_FALLBACKS carries MM/DD/YYYY and no day-first form, so letting it follow the caller's hook onto a typed datetime would read a day-first 05/07/1988 date of birth as a confident May 7. A declared format is the caller stating what their sender means; a built-in fallback is the library guessing, and it does not guess on a clinical datetime. A value matching no declared format stays valid: false with raw intact.

No warning marks a fallback. TIMESTAMP_FALLBACK_FORMAT exists as a code and parseDtmCascade can emit it, but no parse supplies the emit hook it needs, so it does not appear on msg.warnings. matchedFormat on the TS is the caller-visible signal, and it is a structural value rather than a warning for the same reason missing-tz is (no noise on the overwhelmingly common vendor feed).

Non-goals​

Fidelity only: no localization, timezone conversion, or arithmetic; a missing offset is flagged sender-local, never resolved. HHMM=0000 is preserved (never rolled to the previous day). No new warning code. Missing-tz is the structural hasTimezone:false, not a warning (avoids noise on the overwhelmingly common no-offset feed).