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, noDate, 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/rangevalid: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. elseassumeOffsetMinutes→apply it. else→undefined (refuse to guess; never silent UTC).parseDtmDeclared(raw, formats): DtmParts: the TS composite path's parse: tryparseDtm; 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):parseDtmDeclaredfirst, elseBUILTIN_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 isSUPPORTED_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'sdateFormatsare tried ahead of the built-ins, resolve the value, keep the existingTIMESTAMP_FALLBACK_FORMATsemantics, and never report ambiguity. Options precede profile. - One reading still resolves.
07/25/1988(no month 25) and05/05/1988(both readings agree) are unaffected, as are strict DTM, ISO-8601 andYYYY-MM-DD. - The built-in list did not change.
DD/MM/YYYYis used only as a probe for the second reading and never resolves a value: an unambiguous day-first value such as25/07/1988still fails loudly. Declaring the order is the route for a day-first feed. - No warning code was added or renamed.
Hl7Message.warningsis frozen at construction andmsg.metais 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
parseDtmDeclaredomits, sopatient.dateOfBirthand every other typed datetime answer a day-first05/07/1988with a plainvalid: falseand noambiguityreport: nothing consulted a built-in, so there was no second reading to refuse.msg.meta.timestampis 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, soObject.keys()recovers the precision. A component the value did not state is absent, not present-with-undefined. Noraw,valid,precision,hasTimezoneormatchedFormatkey.millisecondis the first three fractional digits verbatim, right-padded (.5=500,.0500=50), never a float multiplication.-0000surfaces asoffsetMinutes: 0.!validor no stated component at all -> undefined.toISO(value): string | undefined: ISO-8601 truncated to the stated precision, fractional digits verbatim. Offset appended asZwhen exactly zero (-0000included), else+HH:MM/-HH:MM; nothing appended when no offset was stated. SotoISOis a rendering andformatDtmremains the byte-exact round trip. No year (which HL7 DTM cannot state) -> undefined.toDate(value, opts?): Date | undefined: delegates todtmToDate, so the zone rule and the sub-100-year handling are the existing ones, unchanged.- All three accept
undefined/nulland returnundefined. None ever throws, for any input. - Options bounds, on the second parameter as well as the first: the bag may be
nullor absent (opts?.is read, not defaulted, because a default parameter fires forundefinedonly), andassumeOffsetMinutesmust be a finite number or the answer isundefined."0",trueand[]all multiply to0in JS, so an unguarded delegation hands back a UTC instant the caller never asked for;NaN, the infinities and an offset past the range aDaterepresents are refused for the same reason, so no answer is ever anInvalid Date. A value stating its own offset never reaches the guard: its offset wins and the assumption is ignored, per the Contract.dtmToDateis 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.
20240230and20230229convert toundefinedrather 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-builtDtmPartsis held to it exactly as a parsed one is.parseDtmis untouched and stays liberal (day 1-31, month-blind), soformatDtmstill 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:59of UTC, which is both what the+HH:MMrendering can state and what an ISO-8601 reader accepts (new Date("2024-02-29T12:00:00+24:00")is anInvalid Date). The same helper does it, so a string, a boolean, an array,NaNand a fractional minute are refused with it. Two routes reach that field without a cast: a hand-builtDtmParts, andmsg.meta.timestamp, whose fallback read range-checks the calendar components and not the offset, so a wire MSH-7 of2024-02-29T12:00:00+99:99states 6039 minutes east. Unbounded,toObjectreports a non-number in anumberslot,toISOrenders+NaN:NaNor a three-digit hour, andtoDatemultiplies anullor a"0"to zero and answers a confident UTC instant.parseDtm(up to+2400),formatDtmanddtmToDateare untouched, exactly as for the calendar bound above. AnoffsetMinuteson a value withhasTimezone: falsestates nothing and is ignored, not refused. - The three names are identical across the
@cosyteparsers, 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
TSis theDtmPartsshape (raw, valid, precision?, parts, hasTimezone, offsetMinutes?). Frozen. No.date.parseTs(rep, enc, dateFormats?)= unescape →parseDtmDeclared.field.ts::asTs()returns it, passing the message's mergeddateFormats.
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).