@cosyte/hl7 0.0.10 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <a href="https://cosyte.com">
2
2
  <picture>
3
3
  <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
4
- <img alt="Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
4
+ <img alt="The Cosyte logo on its own white ground: the icon beside the word Cosyte." src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
5
5
  </picture>
6
6
  </a>
7
7
 
@@ -18,25 +18,112 @@ A developer-focused HL7 v2 parser and utility library for Node.js and TypeScript
18
18
 
19
19
  ---
20
20
 
21
- ## Quickstart
21
+ ## Contents
22
+
23
+ - [Why this exists](#why-this-exists)
24
+ - [Status](#status)
25
+ - [Install](#install)
26
+ - [Usage](#usage)
27
+ - [PHI and safety](#phi-and-safety)
28
+ - [Features](#features)
29
+ - [HL7 in 90 seconds](#hl7-in-90-seconds)
30
+ - [Access patterns](#access-patterns)
31
+ - [Cookbook](#cookbook)
32
+ - [Profiles](#profiles)
33
+ - [Real-World Tolerance](#real-world-tolerance)
34
+ - [Error Handling](#error-handling)
35
+ - [Roadmap](#roadmap)
36
+ - [Contributing](#contributing)
37
+ - [Trademarks](#trademarks)
38
+ - [License](#license)
22
39
 
23
- Three lines of useful output after install + parse. No HL7 spec knowledge required.
40
+ ---
41
+
42
+ ## Why this exists
43
+
44
+ HL7 v2 is the format hospital systems actually speak, and reading one field out of it usually costs you a detour through a 2,000-page specification to learn that the patient's medical record number is `PID-3.1` and the message time is `MSH-7`. This library is for the application developer who has to consume that traffic and would rather not become an HL7 expert first: `msg.patient?.mrn` and `msg.meta.timestamp` are the whole learning curve. The nearest alternative is splitting on `|` and `^` yourself, or reaching for a parser that hands back a positional tree and still expects you to know the segment and field numbers. Those give you structure; this gives you the fields, keeps the positional tree underneath for when you need it, and treats vendor-quirky real-world input as the normal case rather than an error.
45
+
46
+ ---
47
+
48
+ ## Status
49
+
50
+ `0.1.0`. The public API is settled and safe to depend on: the exported functions, the message and helper surfaces, and the 20 stable warning codes are what the version claims, and renaming a warning code counts as a breaking change here.
51
+
52
+ Nothing is queued as forthcoming: [Roadmap](#roadmap) below names no future work, only what is permanently out of scope. `parseStream` parses multi-GB batch files incrementally and yields the messages as an async iterable, and `emitMessageSchema` produces JSON Schema and Zod descriptions of what `toJSON()` returns. Both are exported, so both are covered by the stability claim above.
53
+
54
+ The opt-in structural validator ships too: `validateMessageStructure(msg)` enforces segment ordering and cardinality against HL7's published message structures. See [Check a message against HL7's published shape](#check-a-message-against-hl7s-published-shape-validatemessagestructure).
55
+
56
+ Typed message overlays ship and are covered by the stability claim: `msg.is("ADT^A01")` answers from the message type the parser extracted and narrows the message for the compiler, so the accessors after the check are scoped to the segments that message type's published structure requires. See [Narrow a message to its type](#narrow-a-message-to-its-type).
57
+
58
+ ---
59
+
60
+ ## Install
24
61
 
25
62
  ```bash
26
63
  # pnpm (recommended). Also works with: npm install @cosyte/hl7 | yarn add @cosyte/hl7
27
64
  pnpm add @cosyte/hl7
28
65
  ```
29
66
 
67
+ Requires Node `>=22`. The package ships dual ESM and CommonJS builds with type declarations for both, so `import` and `require` both work and neither needs a compatibility shim. There are zero runtime dependencies: the Node standard library is all it loads.
68
+
69
+ ---
70
+
71
+ ## Usage
72
+
73
+ Parse a message, read fields off it. No HL7 spec knowledge required.
74
+
30
75
  ```ts
31
76
  import { parseHL7 } from "@cosyte/hl7";
32
77
 
33
- const msg = parseHL7(rawHL7);
34
- console.log(msg.patient?.fullName); // "John Q. Doe"
35
- console.log(msg.patient?.mrn); // "MRN12345"
36
- console.log(msg.meta.timestamp?.raw); // "20260419101500" (fidelity TS: precision + zone preserved)
78
+ // A synthetic ADT^A01 admit. HL7 v2 ends every segment with a carriage return.
79
+ const raw =
80
+ "MSH|^~\\&|EPIC|MAIN|LIS|REF|20260419101500||ADT^A01^ADT_A01|MSG00001|P|2.5\r" +
81
+ "EVN|A01|20260419101500\r" +
82
+ "PID|1||MRN12345^^^HOSP^MR||Doe^John^Q||19800115|M|||123 Main St^^Boston^MA^02101||^PRN^PH^^^617^5551212\r" +
83
+ "PV1|1|I|ICU^101^A^HOSP|||||ATTEND^Smith^Jane^^^^MD|||||||||||VISIT001\r";
84
+
85
+ const msg = parseHL7(raw);
86
+
87
+ console.log("Patient record number:", msg.patient?.mrn);
88
+ console.log("Full name:", msg.patient?.fullName);
89
+ console.log("Date of birth:", msg.patient?.dateOfBirth?.raw);
90
+ console.log("Precision:", msg.patient?.dateOfBirth?.precision);
91
+ console.log("Message type:", msg.meta.type);
92
+ console.log("Sent at:", msg.meta.timestamp?.raw);
93
+ console.log("Ward:", msg.visit?.location?.pointOfCare);
37
94
  ```
38
95
 
39
- That's the whole pitch: no config, no schema upload, no spec lookup. The parser accepts vendor-quirky input by default, strips MLLP framing if it's there, normalises casing, and tolerates the dozen-or-so deviations real HL7 traffic routinely carries. You reach for strict mode, dot-paths, or profiles when you want them, not before.
96
+ ```text
97
+ Patient record number: MRN12345
98
+ Full name: John Q Doe
99
+ Date of birth: 19800115
100
+ Precision: day
101
+ Message type: ADT^A01^ADT_A01
102
+ Sent at: 20260419101500
103
+ Ward: ICU
104
+ ```
105
+
106
+ The test suite reads that block out of this README, runs it against the package, and fails if the output above ever stops matching what the code prints. The message is a copy of a synthetic fixture from the repository's test corpus.
107
+
108
+ Note `precision`: a birth date is a day, not an instant, and every datetime is a fidelity `TS` that keeps the precision and timezone it arrived with rather than guessing a `Date`.
109
+
110
+ That is the whole pitch: no config, no schema upload, no spec lookup. The parser accepts vendor-quirky input by default, strips MLLP framing if it's there, normalises casing, and tolerates the dozen-or-so deviations real HL7 traffic routinely carries. You reach for strict mode, dot-paths, or profiles when you want them, not before.
111
+
112
+ ---
113
+
114
+ ## PHI and safety
115
+
116
+ HL7 v2 messages carry patient data, so what this library does with the bytes you hand it is part of its contract.
117
+
118
+ **It does not log.** There is no logger, no debug channel and no `console` call anywhere in the library: diagnostics are returned to you, never printed. Tolerated deviations come back as `Hl7ParseWarning` values carrying a stable code, a bounded message and a position (`segmentIndex`, `fieldIndex`), never the field value that triggered them, so a warning is safe to log as-is.
119
+
120
+ **One field is an exception, and it is deliberate.** `Hl7ParseError.snippet` carries up to 40 characters of your input verbatim and unredacted so a fatal parse failure is actionable. It is the only field that echoes input without filtering, the library does not redact it, and it is the first thing to strip if your compliance posture requires it. `Hl7ParseError.message` is bounded rather than absolutely content-free: it echoes a token only where the token matches a shape the spec defines (a three-character segment identifier, a message type, a version, a known charset label) and withholds anything else.
121
+
122
+ **It retains nothing you do not hold.** Parsing returns an `Hl7Message` and the message content lives on that object and nowhere else. There is no cache, no pool and no module-level store of message data, so the content is released when you drop your reference to it. The one piece of process-wide state the library keeps is an optional registered default profile, which holds parsing configuration, not patient data.
123
+
124
+ **It writes nothing and sends nothing.** No file is opened, no directory is written and no network connection is made: the library imports only `node:buffer` and `node:crypto` from the standard library and has zero runtime dependencies. `parseStream` reads a stream you supply and opens nothing itself. `toString()`, `toJSON()` and `prettyPrint()` hand the message content back to you by design; where it goes next is your call, and `prettyPrint()` in particular is a debug view of real patient data, not a redacted one.
125
+
126
+ **What stays yours.** Transport security, storage, retention and audit, redaction before anything reaches a log or an error tracker, and access control are all the consuming application's. A parser cannot make a system HIPAA-compliant, and this one does not claim to.
40
127
 
41
128
  ---
42
129
 
@@ -44,6 +131,8 @@ That's the whole pitch: no config, no schema upload, no spec lookup. The parser
44
131
 
45
132
  - **One-line extraction**: `msg.patient.mrn`, `msg.meta.timestamp`, `msg.observations()`, and friends. No segment or field numbers to memorise.
46
133
  - **Three access patterns**: named helpers, dot-paths (`msg.get("PID.5.1")`), or structural traversal (`msg.segments("OBX")[0].field(3)`). Pick the level of ceremony you need.
134
+ - **Typed message overlays**: `msg.is("ADT^A01")` answers at run time and narrows at compile time, so the message code and trigger event become literal types and `part` / `parts` are scoped to the segments that message type's published structure requires. No cast, no per-message-type import.
135
+ - **Opt-in published-structure validation**: `validateMessageStructure(msg)` checks segment order, occurrence counts and unnamed segments against HL7's own published message structures, derived offline from a vendored snapshot. Read-only, no new warning code, and nothing changes for a caller who does not ask.
47
136
  - **Real-world tolerance, four-tier**: lenient default parses vendor-quirky messages; 20 stable warning codes flag what was tolerated; strict mode escalates every deviation for CI validators; only 4 truly-structural failures are fatal.
48
137
  - **First-class profile system**: `defineProfile()` API, 8 built-in vendor profiles (Epic, Cerner, Meditech, athenahealth, generic lab, Visage 7 imaging/PACS, Philips Vue PACS, VA VistA Radiology/NucMed), plus a [publishable starter kit](./examples/profile-starter-kit/) you copy-and-ship.
49
138
  - **Round-trip safe, byte-verbatim escapes**: `parse -> modify -> toString()` emits spec-clean HL7 regardless of input quirks (Postel's Law: liberal parser, conservative emitter), and a parsed field's escape sequences (`\H\`, `\X41\`, charset/vendor escapes) re-emit **byte-for-byte**. See [Escapes & round-trip](./docs-content/spec-notes-escapes.md).
@@ -62,7 +151,7 @@ A typical ADT message looks like this:
62
151
  Message
63
152
  ├── MSH (header: sender, receiver, type, timestamp)
64
153
  ├── EVN (event details for ADT)
65
- ├── PID (patient identification: name, MRN, DOB)
154
+ ├── PID (patient identification: name, record number, DOB)
66
155
  ├── PV1 (visit: class, location, attending)
67
156
  └── OBX×N (observations: repeats for labs, vitals)
68
157
 
@@ -143,7 +232,7 @@ Runnable recipes for the tasks developers hit most often. Every snippet imports
143
232
 
144
233
  ### Patient demographics
145
234
 
146
- Reach for the `msg.patient` helper for the name/MRN/DOB trio that covers 95% of demographic extractions.
235
+ Reach for the `msg.patient` helper for the name, record-number and date-of-birth trio that covers 95% of demographic extractions.
147
236
 
148
237
  ```ts
149
238
  import { parseHL7 } from "@cosyte/hl7";
@@ -215,7 +304,7 @@ const outbound = msg.toString(); // spec-clean HL7 wire format
215
304
 
216
305
  ### Allergies
217
306
 
218
- `msg.allergies()` walks every `AL1` segment and returns a typed list.
307
+ `msg.allergies()` walks every `AL1` and every `IAM` segment and returns a typed list, one entry per segment in document order. An ADT^A60 carries its allergies in `IAM` and no `AL1`, so it is read too. Each entry names the segment it came from in `source`.
219
308
 
220
309
  ```ts
221
310
  import { parseHL7 } from "@cosyte/hl7";
@@ -223,12 +312,26 @@ import { parseHL7 } from "@cosyte/hl7";
223
312
  const msg = parseHL7(raw);
224
313
 
225
314
  for (const al of msg.allergies()) {
226
- console.log(al.code?.text, al.severity, al.reaction);
227
- // "Penicillin" "SV" "Hives"
315
+ console.log(al.source, al.code?.text, al.severity, al.reaction);
316
+ // "IAM" "Penicillin" "SV" "Hives"
317
+ if (al.deleteRequested === true) {
318
+ // IAM-6 was "D": the sender asks you to delete the allergy it sent earlier
319
+ // under al.uniqueIdentifier?.entityIdentifier. Nothing was deleted for you.
320
+ }
228
321
  }
229
322
  ```
230
323
 
231
- Fields are parsed into their spec-typed shapes (`code` is a `CWE` composite, `onsetDate` is a fidelity `TS`). The same helper family exists for next-of-kin (`msg.nextOfKin()`), diagnoses (`msg.diagnoses()`), insurance (`msg.insurance()`), medications (`msg.medications()`), and immunizations (`msg.immunizations()`).
324
+ Fields are parsed into their spec-typed shapes (`code` is a `CWE` composite, `onsetDate` is a fidelity `TS`). An `IAM` entry also carries `actionCode` (IAM-6 exactly as sent, absent when IAM-6 is empty rather than assumed to be `A`) and `uniqueIdentifier` (IAM-7 components 1 and 2).
325
+
326
+ What it does not do:
327
+
328
+ - It never applies an action. A `D` entry is still returned, marked `deleteRequested: true`; a `U` entry never replaces an earlier one; an `AL1` and an `IAM` for the same allergen are two entries. The library holds no allergy list to apply them to, so that is the receiver's call.
329
+ - It does not read the `IAR` (adverse reaction) or `NTE` segments under an `IAM`, nor IAM-1 or IAM-8 onward, and it reads IAM-2 and IAM-4 as component 1 and IAM-5 as its first repetition. Reach the rest with `msg.segments("IAM")`.
330
+ - A missing IAM-7 is left missing: IAM-3 is never used in its place.
331
+
332
+ The same helper family exists for next-of-kin (`msg.nextOfKin()`), diagnoses (`msg.diagnoses()`), insurance (`msg.insurance()`), medications (`msg.medications()`), and immunizations (`msg.immunizations()`). Each medication carries `orderControl`, the ORC-1 order control of the `ORC` that opened its order group (`NW`, `DC`, `HD` and so on) exactly as sent, never an interpreted state such as active, held or discontinued, and absent when no `ORC` opens the group or ORC-1 is empty.
333
+
334
+ Each immunization carries `administrationStatus`, read from that RXA's own RXA-21, RXA-5 and RXA-20: `completed` for a dose given (RXA-20 `CP` or `PA`), `not-done` for a refused or not-administered dose (`RE` or `NA`), `no-vaccine-administered` for a CVX `998` placeholder, `delete-requested` for an RXA-21 `D` asking to delete an administration sent earlier, and `undetermined` for anything not exactly mapped, never `completed`. The raw RXA-20 and RXA-21 codes ride beside it with the field and table that decided. It is reported, never applied: a `D` record is still returned and nothing is removed. See the [immunization status notes](./docs-content/spec-notes-immunization-status.md).
232
335
 
233
336
  ### Scheduling, documents & charges (SIU · MDM · DFT)
234
337
 
@@ -283,7 +386,9 @@ The repeat pattern (Table 0335) is surfaced **verbatim**: hl7 never normalizes a
283
386
 
284
387
  ### Patient merges and identity events
285
388
 
286
- `msg.identityEvents()` recognizes the ADT identity-management trigger events: merges (A18/A34/A35/A36/A39/A40/A41/A42), moves (A43/A44), link/unlink (A24/A37), and person add/update (A28/A31). It surfaces every party **labelled by role**. On a merge, the MRG segment carries the _prior_ (non-surviving) identifiers and the PID carries the _surviving_ ones; the direction is the spec constant `MRG_TO_PID` and is never inferred from content.
389
+ `msg.identityEvents()` recognizes the ADT identity-management trigger events: merges (A18/A34/A35/A36/A39/A40/A41/A42), moves (A43/A44/A45), identifier changes (A47/A49/A50/A51), link/unlink (A24/A37), and person add/update (A28/A31). It surfaces every party **labelled by role**. On a merge, move or change, the MRG segment carries the _prior_ (non-surviving) identifiers and the PID carries the _surviving_ ones; the direction is the spec constant `MRG_TO_PID` and is never inferred from content.
390
+
391
+ The recognized set is floored by the published message structures: every ADT trigger event whose structure requires an MRG segment is recognized, so the read side cannot fall behind the structures the parser validates against. `kind` stays meaningful across that floor: an identifier change (`"change"`) replaces one identifier on one record and is never reported as a `"merge"`, so a consumer that acts only on `kind === "merge"` never starts conflating records on an event it did not expect.
287
392
 
288
393
  ```ts
289
394
  import { parseHL7 } from "@cosyte/hl7";
@@ -300,7 +405,7 @@ for (const ev of msg.identityEvents()) {
300
405
  }
301
406
  ```
302
407
 
303
- A mis-applied merge conflates two patients or orphans data under a retired MRN, so the helper is deliberately conservative: an incomplete pair (no MRG, an orphaned MRG, or a PID with no surviving identifier) surfaces whatever _is_ present plus a `MERGE_MISSING_PRIOR_OR_SURVIVOR` warning on the event: the MRG is never dropped, and the direction is never guessed. The MRG field map is version-scoped: the backward-compat single-ID fields (PID-2 / MRG-4, withdrawn as of HL7 v2.7) are not read when MSH-12 declares v2.7+. Note that `@cosyte/hl7` _surfaces_ the merge. Actually re-pointing stored data to the survivor is your integration engine's job.
408
+ A mis-applied merge conflates two patients or orphans data under a retired record number, so the helper is deliberately conservative: an incomplete pair (no MRG, an orphaned MRG, or a PID with no surviving identifier) surfaces whatever _is_ present plus a `MERGE_MISSING_PRIOR_OR_SURVIVOR` warning on the event: the MRG is never dropped, and the direction is never guessed. The MRG field map is version-scoped: the backward-compat single-ID fields (PID-2 / MRG-4, withdrawn as of HL7 v2.7) are not read when MSH-12 declares v2.7+. Note that `@cosyte/hl7` _surfaces_ the merge. Actually re-pointing stored data to the survivor is your integration engine's job.
304
409
 
305
410
  ### Write your first profile in 10 minutes
306
411
 
@@ -427,11 +532,167 @@ hl7 preserves precision + timezone **fidelity**; it does **not** localize, conve
427
532
  timestamps. A consumer needing an absolute instant applies the sender's zone via `assumeOffsetMinutes`.
428
533
  Use `parseDtm` / `formatDtm` / `dtmToDate` directly on a raw string when you're outside the message model.
429
534
 
535
+ #### Converting a datetime: `toObject`, `toISO`, `toDate`
536
+
537
+ Three conversion helpers sit on top of the fidelity `TS`. Every `@cosyte` parser exposes these same
538
+ three names with the same semantics, so you learn the conversion story once and it holds across the
539
+ suite.
540
+
541
+ | function | returns | gives you |
542
+ | ------------------------- | ---------------------------------- | ------------------------------------------------------ |
543
+ | `toObject(value)` | frozen `DateParts`, or `undefined` | the stated calendar components as a plain object |
544
+ | `toISO(value)` | `string`, or `undefined` | ISO-8601, truncated to the stated precision |
545
+ | `toDate(value, options?)` | `Date`, or `undefined` | an absolute instant, only when the zone is determinate |
546
+
547
+ ```ts
548
+ import { parseDtm, toObject, toISO } from "@cosyte/hl7";
549
+
550
+ toObject(parseDtm("19880705"));
551
+ // { year: 1988, month: 7, day: 5 }: exactly the stated components, nothing zero-filled
552
+
553
+ toISO(parseDtm("19880705")); // "1988-07-05": truncated to the stated precision, no fabricated Z
554
+ toISO(parseDtm("20250102153045.5-0500")); // "2025-01-02T15:30:45.5-05:00": digits verbatim
555
+ ```
556
+
557
+ `toObject` carries only the components the value stated, so `Object.keys()` recovers its precision and
558
+ nothing is invented: no `raw`, no `valid`, no `precision`. `month` is spec-native 1 to 12, which is
559
+ exactly what `Temporal.PlainDateTime.from({...})` and luxon's `DateTime.fromObject({...})` accept:
560
+ delete `offsetMinutes` and hand the rest over with no key rename and no value adjustment. Neither
561
+ library is a dependency here, and neither needs to be. `millisecond` is the first three fractional
562
+ digits taken verbatim and right-padded, so `.5` is 500 ms and `.0500` is 50 ms, never a float
563
+ multiplication that loses the last digit. `offsetMinutes` appears if and only if the value carried an
564
+ explicit offset, and is never synthesised from the host machine's zone.
565
+
566
+ **`toDate` never guesses a zone.** An offset-less value returns `undefined` unless you say which zone
567
+ it was written in:
568
+
569
+ ```ts
570
+ import { parseDtm, toDate } from "@cosyte/hl7";
571
+
572
+ toDate(parseDtm("20250102")); // undefined: no offset stated, and none assumed
573
+ toDate(parseDtm("20250102"), { assumeOffsetMinutes: 0 }); // 2025-01-02T00:00:00.000Z: you chose UTC
574
+ toDate(parseDtm("20250102"), { assumeOffsetMinutes: -300 }); // 2025-01-02T05:00:00.000Z
575
+
576
+ // A stated offset always wins, and assumeOffsetMinutes is ignored rather than blended:
577
+ toDate(parseDtm("20250102153045-0500")); // 2025-01-02T20:30:45.000Z
578
+ toDate(parseDtm("20250102153045-0500"), { assumeOffsetMinutes: 600 }); // the same instant
579
+ ```
580
+
581
+ The host machine's timezone is never read and UTC is never assumed, so the answer does not depend on
582
+ where your code ran. Components below the stated precision fill to their lowest legal value for the
583
+ instant only, leaving the value's own precision untouched, and a four-digit year below 100 stays that
584
+ year (`00500101` is year 50, never 1950).
585
+
586
+ **An offset that is not a finite number is no offset at all.** `assumeOffsetMinutes` is signed
587
+ minutes east of UTC, and anything else names no zone, so `toDate` answers `undefined` rather than
588
+ coercing it into one. That matters from JavaScript, where the type is not checked for you:
589
+
590
+ ```ts
591
+ import { parseDtm, toDate } from "@cosyte/hl7";
592
+
593
+ const dob = parseDtm("20240229"); // day precision, no offset stated
594
+
595
+ toDate(dob, null); // undefined: no options bag is not a zone, and this does not throw
596
+ toDate(dob, {}); // undefined: same answer as passing nothing
597
+ toDate(dob, { assumeOffsetMinutes: "0" }); // undefined: a string is not a number of minutes
598
+ toDate(dob, { assumeOffsetMinutes: Number.NaN }); // undefined: neither is NaN
599
+
600
+ toDate(dob, { assumeOffsetMinutes: 0 }); // 2024-02-29T00:00:00.000Z: a real choice, honoured
601
+ ```
602
+
603
+ `"0"`, `true` and `[]` all multiply to `0` in JavaScript, so a converter that simply did the
604
+ arithmetic would hand back a UTC instant nobody asked for, which is the guess this surface exists to
605
+ refuse. An offset large enough to leave the range a `Date` represents is refused the same way, so no
606
+ answer is ever an `Invalid Date`. A value that states its own offset is unaffected: its offset wins
607
+ outright, so an unusable assumption beside one is ignored rather than fatal.
608
+
609
+ **`toISO` renders, `formatDtm` round-trips.** A stated zero offset renders as `Z`, including HL7's
610
+ `-0000` form, so the two answers differ by design and `formatDtm` remains the byte-exact route back to
611
+ the wire:
612
+
613
+ ```ts
614
+ import { formatDtm, parseDtm, toISO } from "@cosyte/hl7";
615
+
616
+ const ts = parseDtm("20250102153045-0000");
617
+ toISO(ts); // "2025-01-02T15:30:45Z"
618
+ formatDtm(ts); // "20250102153045-0000": byte-exact, sign preserved
619
+ ```
620
+
621
+ All three return `undefined` rather than throwing for a value the parser marked invalid, for
622
+ `undefined` and for `null`.
623
+
624
+ **An impossible date converts to nothing, never to the day after it.** A value stating a day its
625
+ month does not have is refused by all three, whole, rather than rolled over into the following
626
+ month. February really has 28 days, or 29 in a leap year under the full 4/100/400 rule:
627
+
628
+ ```ts
629
+ import { parseDtm, toDate, toISO, toObject } from "@cosyte/hl7";
630
+
631
+ toObject(parseDtm("20240230")); // undefined: February has no 30th
632
+ toISO(parseDtm("20230229")); // undefined: 2023 is not a leap year
633
+ toDate(parseDtm("21000229"), { assumeOffsetMinutes: 0 }); // undefined: 2100 is not one either
634
+
635
+ toISO(parseDtm("20240229")); // "2024-02-29": 2024 is, so this one converts
636
+ ```
637
+
638
+ `month` is bounded 1 to 12, `day` by the month it is in, `hour` 0 to 23, and `minute` and `second`
639
+ 0 to 59. The parser itself is unchanged and stays deliberately liberal, so `parseDtm("20240230")`
640
+ is still `valid: true` and `formatDtm` still round-trips those bytes: the refusal is in the
641
+ conversion, which is where a wrong answer would otherwise look right. Rendering it would be worse
642
+ than throwing, because `new Date("2024-02-30")` is 1 March in every JavaScript runtime, so a
643
+ consumer reading the string gets a silent one-day shift instead of an error.
644
+
645
+ **The offset a value states is bounded on the same terms.** When a value claims an explicit offset,
646
+ that offset has to be a whole number of minutes within 23 hours 59 minutes of UTC, which is exactly
647
+ what `+HH:MM` can state and what an ISO-8601 reader accepts. Anything else names no zone, so the
648
+ value converts to `undefined` rather than being reported as an offset, rendered into a string no
649
+ reader can read back, or turned into an instant:
650
+
651
+ ```ts
652
+ import { parseHL7, toDate, toISO } from "@cosyte/hl7";
653
+
654
+ // A timestamp on the wire, read by the lenient fallback, stating +99:99:
655
+ const ts = parseHL7("MSH|^~\\&|SEND|FAC|RECV|FAC|2024-02-29T12:00:00+99:99||ADT^A01|MSG1|P|2.5\r")
656
+ .meta.timestamp;
657
+
658
+ toISO(ts); // undefined: 6039 minutes east of UTC is no zone, and "+100:39" is unreadable
659
+ toDate(ts); // undefined: an offset that names no zone yields no instant
660
+ ```
661
+
662
+ The same bound catches an `offsetMinutes` a JavaScript caller puts on a value by hand, where a
663
+ `"0"`, a `true` or an `[]` would otherwise multiply to `0` and answer a confident UTC instant, and
664
+ it catches `NaN`, which would otherwise render as `+NaN:NaN`. Every real zone on Earth is far inside
665
+ it (the widest in use is 14 hours east), a stated offset still wins outright over any
666
+ `assumeOffsetMinutes` beside it, and `parseDtm`, `formatDtm` and `dtmToDate` are unchanged.
667
+
668
+ **Using two `@cosyte` parsers in one file.** The three names are identical in `@cosyte/hl7`,
669
+ `@cosyte/x12`, `@cosyte/dicom`, `@cosyte/ncpdp`, `@cosyte/astm` and `@cosyte/ccda`, so importing two
670
+ of them into one file collides. Alias on import:
671
+
672
+ ```ts
673
+ import { parseDtm, toISO as hl7ToISO } from "@cosyte/hl7";
674
+ import { toISO as x12ToISO } from "@cosyte/x12";
675
+
676
+ hl7ToISO(parseDtm("19880705")); // "1988-07-05"
677
+ // x12ToISO does the same for an X12 date value: same three names, same semantics.
678
+ ```
679
+
680
+ ...or namespace-import, which keeps the package of origin visible at every call site:
681
+
682
+ ```ts
683
+ import * as hl7 from "@cosyte/hl7";
684
+ import * as x12 from "@cosyte/x12";
685
+
686
+ hl7.toISO(hl7.parseDtm("19880705")); // "1988-07-05"
687
+ // x12.toISO(...) is the same call, against an X12 date value.
688
+ ```
689
+
430
690
  #### Non-standard timestamp formats
431
691
 
432
- HL7's canonical `YYYYMMDDHHmmss` parses with zero warnings. Everything else (vendor-quirky
433
- `MM/DD/YYYY`, ISO `YYYY-MM-DD`, legacy `YYYYMMDD HHmm`) parses via the `dateFormats` option, which
434
- tries each format in order and populates the same fidelity `TS`.
692
+ HL7's canonical `YYYYMMDDHHmmss` parses with zero warnings. For everything else (vendor-quirky
693
+ `MM/DD/YYYY`, ISO `YYYY-MM-DD`, legacy `YYYYMMDD HHmm`), tell the parser what your sender writes
694
+ using the `dateFormats` option. Each format is tried in order, the first match wins, and it
695
+ populates the same fidelity `TS`.
435
696
 
436
697
  ```ts
437
698
  import { parseHL7 } from "@cosyte/hl7";
@@ -440,10 +701,72 @@ const msg = parseHL7(raw, {
440
701
  dateFormats: ["MM/DD/YYYY HH:mm:ss", "MM/DD/YYYY", "YYYY-MM-DD"],
441
702
  });
442
703
 
443
- console.log(msg.meta.timestamp?.matchedFormat); // e.g. "MM/DD/YYYY" (which fallback won)
704
+ console.log(msg.meta.timestamp?.matchedFormat); // e.g. "MM/DD/YYYY": which declared format won
705
+ console.log(msg.patient?.dateOfBirth?.matchedFormat); // the same list reaches PID-7
706
+ ```
707
+
708
+ **The formats you declare reach every datetime the library returns**, not just the message header
709
+ timestamp: `patient.dateOfBirth`, `visit.admitDateTime` / `dischargeDateTime`, each
710
+ `observations()` entry's `observedDateTime` and its `TS`/`DT` typed value, `allergies()`
711
+ onset dates, `diagnoses()` date/times, `insurance()` effective and expiration dates,
712
+ `immunizations()` administered and expiration dates, `charges()` transaction dates,
713
+ `documents()` activity date/times, order and medication `timings` (a `TQ1` segment and the legacy
714
+ embedded `TQ` alike), and `appointments()` start and end times.
715
+
716
+ `matchedFormat` is how you tell which answer you got: it names the declared format that matched,
717
+ and is absent when the value was canonical HL7 and parsed strictly. There is no parse warning for
718
+ either case.
719
+
720
+ **Only the formats you declare are tried on those fields.** A date the parser was not told about
721
+ stays `valid: false` with its `raw` text intact, rather than being guessed at: guessing is how a
722
+ day-first `05/07/1988` becomes a confident May 7 on a date of birth, and a plausible wrong date is
723
+ worse than a missing one. `BUILTIN_DATE_FALLBACKS` (ISO-8601 and the US-order slash forms) is a
724
+ last resort for `msg.meta.timestamp` alone and never runs on a typed datetime field.
725
+
726
+ A format is written from a fixed token vocabulary (`SUPPORTED_DATE_TOKENS`) that covers month names (`05-JUL-1988`), a 12-hour clock with AM/PM (`7/5/1988 2:30 PM`), single-digit tolerance (`M`, `D`, `H`) and escaped literals (`YYYY-MM-DD[T]HH:mm:ss`). It carries no two-digit-year token, because resolving one needs a century window and a wrong window moves a date of birth by a hundred years without failing. `defineProfile()` refuses a format the vocabulary cannot honour, at definition time, rather than accepting it and never matching. The whole grammar, with every token, rule and exclusion, is the `Date token grammar` page in the documentation.
727
+
728
+ Built-in vendor profiles (`profiles.epic`, `profiles.genericLab`, etc.) already carry the date formats common to that vendor, and an option format is tried ahead of a profile's. Reach for a profile instead of hand-listing formats when one fits.
729
+
730
+ #### Day-first vs month-first: the parser refuses to guess
731
+
732
+ `05/07/1988` is 5 July to a day-first sender and May 7 to a month-first one. Both are real calendar
733
+ dates, and nothing in the message says which was meant. When no format has been declared, the
734
+ parser resolves **neither**: `msg.meta.timestamp` comes back `valid: false` carrying an `ambiguity`
735
+ report that names the raw value and both readings, so a wrong date never reaches your code silently.
736
+
737
+ ```ts
738
+ import { parseHL7, AMBIGUOUS_DATE_ORDER } from "@cosyte/hl7";
739
+
740
+ const ts = parseHL7(raw).meta.timestamp; // MSH-7 was "05/07/1988"
741
+
742
+ if (ts?.ambiguity?.code === AMBIGUOUS_DATE_ORDER) {
743
+ console.log(ts.ambiguity.raw); // "05/07/1988"
744
+ console.log(ts.ambiguity.candidates[0]); // { format: "MM/DD/YYYY", month: 5, day: 7, isoDate: "1988-05-07" }
745
+ console.log(ts.ambiguity.candidates[1]); // { format: "DD/MM/YYYY", month: 7, day: 5, isoDate: "1988-07-05" }
746
+ }
747
+ ```
748
+
749
+ Declaring the sender's order is the fix, and it is one line. A declared format is tried ahead of the
750
+ built-ins, so the value resolves and no ambiguity is reported:
751
+
752
+ ```ts
753
+ const dayFirst = parseHL7(raw, { dateFormats: ["DD/MM/YYYY"] });
754
+ console.log(dayFirst.meta.timestamp?.month); // 7: 5 July 1988
755
+
756
+ const monthFirst = parseHL7(raw, { dateFormats: ["MM/DD/YYYY"] });
757
+ console.log(monthFirst.meta.timestamp?.month); // 5: May 7 1988
444
758
  ```
445
759
 
446
- When a fallback format wins, the parser emits a `TIMESTAMP_FALLBACK_FORMAT` warning with the matched format on `msg.warnings`. Built-in vendor profiles (`profiles.epic`, `profiles.genericLab`, etc.) already carry the date formats common to that vendor. Reach for a profile instead of hand-listing formats when one fits.
760
+ Only genuinely two-way values are refused. `07/25/1988` has one reading (there is no month 25) and
761
+ still resolves as July 25. `05/05/1988` has two readings that agree and still resolves. Strict HL7
762
+ timestamps, ISO-8601 values and `YYYY-MM-DD` values are untouched. Ambiguity is also a different fact
763
+ from malformed input: a value that is illegal under both readings reports no timestamp and no
764
+ ambiguity.
765
+
766
+ The report belongs to `msg.meta.timestamp`, because it is the built-in list that produces the second
767
+ reading and that list runs there and nowhere else. On a typed datetime field, an undeclared
768
+ `05/07/1988` is simply `valid: false` with its `raw` intact and no `ambiguity`: nothing tried to read
769
+ it, so there was no guess to refuse. Declaring the order resolves it on every field at once.
447
770
 
448
771
  ### Stripping MLLP framing
449
772
 
@@ -532,9 +855,38 @@ if (msg.meta.messageCode === "ORU") {
532
855
 
533
856
  Matching on `messageCode` + `triggerEvent` is more robust than string-equals on `type`, because some senders populate MSH-9.3 (`type` includes it) and some don't.
534
857
 
858
+ ### Narrow a message to its type
859
+
860
+ `msg.is("ADT^A01")` does the same comparison and tells the **compiler** the answer. Inside the guard the message code and trigger event are literal types rather than `string | undefined`, and `part` / `parts` accept only the segment names that message type's published structure marks required. No cast, and no type to import per message type.
861
+
862
+ ```ts
863
+ import { parseHL7 } from "@cosyte/hl7";
864
+
865
+ const msg = parseHL7(raw);
866
+
867
+ if (msg.is("ADT^A01")) {
868
+ const code: "ADT" = msg.meta.messageCode; // literal, and no longer optional
869
+ const event: "A01" = msg.meta.triggerEvent;
870
+ console.log(code, event);
871
+
872
+ const pid = msg.part("PID"); // Segment | undefined: first PID, or none
873
+ const evn = msg.parts("EVN"); // readonly Segment[]: every EVN, possibly empty
874
+ console.log(pid?.field(3).value, evn.length);
875
+
876
+ // msg.part("OBX"); // does not compile: ADT^A01 does not require OBX
877
+ console.log(msg.segments("OBX").length); // the base accessor still takes any name
878
+ }
879
+ ```
880
+
881
+ A key is `"<MSH-9.1>^<MSH-9.2>"` (`"ADT^A01"`), or `"<MSH-9.1>"` alone for a message type the published structure registry matches on message code alone (`"ACK"`, whose MSH-9.2 carries the acknowledged message's trigger event). `SUPPORTED_OVERLAY_MESSAGES` enumerates every key with the segments its structure requires, derived from that registry rather than written down.
882
+
883
+ The check is on the (MSH-9.1, MSH-9.2) pair the parser extracted, so a message whose MSH-9 carries the three-component `ADT^A01^ADT_A01` still answers `true` to `is("ADT^A01")`. **Any string that is not a key returns `false` and never throws**: an unrecognized type, the three-component form as a string, an empty string, or a value computed at run time (which cannot narrow anything, so it returns a plain `boolean`). For a raw comparison, `msg.meta.type` is the string MSH-9 carried.
884
+
885
+ Narrowing does not promise presence. The parser is lenient by design, and an `ADT^A01` that arrives without its `PID` still answers `true` here, still warns, and still reports the absence on `msg.structure`: `part` returns `undefined` and `parts` an empty list, exactly as `segments` does.
886
+
535
887
  ### Spot a truncated or misrouted message
536
888
 
537
- For the common message types, `msg.structure` reports whether the core segment groups the HL7 spec marks **Required** for that trigger event are actually present. It's a **misroute / truncation safety net**, not a conformance validator: an `ORU^R01` that arrives with no `OBR`/`OBX` result group is almost always truncated or sent to the wrong feed.
889
+ For the message types it covers, `msg.structure` reports whether the segments HL7's own published message-structure definitions give a **minimum of one** for that trigger event are actually present. It's a **misroute / truncation safety net**, not a conformance validator: an `ORU^R01` that arrives with no `OBR` is almost always truncated or sent to the wrong feed.
538
890
 
539
891
  ```ts
540
892
  import { parseHL7, WARNING_CODES } from "@cosyte/hl7";
@@ -542,16 +894,43 @@ import { parseHL7, WARNING_CODES } from "@cosyte/hl7";
542
894
  const msg = parseHL7(raw); // ORU^R01 with only MSH + PID
543
895
 
544
896
  console.log(msg.structure.recognized); // true
545
- console.log(msg.structure.missingGroups); // ["result"] (no OBR/OBX)
897
+ console.log(msg.structure.missingSegments); // ["OBR"]
898
+ console.log(msg.structure.structureIds); // ["ORU_R01-A", "ORU_R01-B", ...]
546
899
 
547
900
  // The same finding also surfaces as an additive Tier-2 warning, so a
548
901
  // channel can route on it without inspecting `structure`:
549
902
  if (msg.warnings.some((w) => w.code === WARNING_CODES.MISSING_EXPECTED_GROUP)) {
550
- // quarantine / alert: the message is missing an expected segment group
903
+ // quarantine / alert: the message is missing an expected segment
551
904
  }
552
905
  ```
553
906
 
554
- The check is **conservative by construction**: it only models groups that are genuinely Required (so a conformant-but-sparse message never warns), it keys on the **trigger event** (not the message family), and a type it doesn't recognize yields `recognized: false` and emits nothing. It never throws and never rewrites the message. `strict` mode may promote the warning to an error per the usual model. Recognized types: ADT (A01/A02/A03/A04/A05/A08/A11/A13), ORU^R01, ORM^O01, OML^O21, OMG^O19, OMP^O09, OMI^O23, SIU (S12–S26), MDM (T02/T06), DFT^P03, VXU^V04, and ACK. See [`docs-content/spec-notes-structure.md`](docs-content/spec-notes-structure.md) for the per-type Required-segment sourcing.
907
+ Expectations are **derived, not hand-picked**: the package vendors a byte-for-byte snapshot of the published structures and derives the registry from it offline, with no network call at build time or run time. `STRUCTURE_REGISTRY_PROVENANCE` carries the publication, its commit, the sha256 of every vendored file and the structure behind every recognized pair, so a warning can be audited without leaving the package.
908
+
909
+ It stays conservative where the publication is: a segment inside an optional group is not expected (so a conformant `OBX`-free `ORU^R01` never warns), a segment is expected only when every published variant of the structure requires it, and a type it doesn't recognize yields `recognized: false` and emits nothing. It never throws and never rewrites the message. `strict` mode may promote the warning to an error per the usual model. Recognized message codes: ADT, ORU, ORM, OML, OMG, OMP, OMI, SIU, MDM, DFT, VXU and ACK, across 94 trigger-event pairs. See [`docs-content/spec-notes-structure.md`](docs-content/spec-notes-structure.md) for the full table, the derivation rules, and what changed for consumers.
910
+
911
+ ### Check a message against HL7's published shape (`validateMessageStructure`)
912
+
913
+ `msg.structure` asks only whether a required segment is **present**. When the question is "does this feed follow the shape the standard publishes for this trigger event?", ask for `validateMessageStructure(msg)`: an **opt-in, read-only** check over the same vendored publication, at segment granularity. Nothing calls it for you and it changes nothing: no new warning code, no change to a parse, and the message is byte-identical afterwards.
914
+
915
+ ```ts
916
+ import { parseHL7, validateMessageStructure } from "@cosyte/hl7";
917
+
918
+ const result = validateMessageStructure(parseHL7(raw)); // ADT^A01 with PV1 before PID
919
+
920
+ console.log(result.validated); // true
921
+ console.log(result.structureId); // "ADT_A01-A": the variant the findings are against
922
+ console.log(result.structureIds); // the whole variant family that was considered
923
+ for (const f of result.findings) console.log(f.severity, f.code, f.locus.segment, f.message);
924
+ // e.g. 'error STRUCTURE_SEGMENT_OUT_OF_ORDER PID Segment "PID" (occurrence 0) appears where published structure ADT_A01-A does not allow it.'
925
+ ```
926
+
927
+ It reports three things: `STRUCTURE_SEGMENT_OUT_OF_ORDER` (the sequence stops being a beginning the published order allows), `STRUCTURE_SEGMENT_CARDINALITY` (fewer occurrences than the published minimum along the path from the structure root, or more than the published maximum), and `STRUCTURE_SEGMENT_UNEXPECTED` (a segment the publication does not name, a `Z` segment included, at `warning` severity). Every finding carries a **PHI-free locus**: a segment name, a 0-indexed occurrence and a published structure id, never a field value.
928
+
929
+ The order check reads the publication's bounds as written, at every locus: a group it lets repeat may repeat, which is what lets a conformant `ORU^R01` carry `OBR OBX OBR OBX`, a group bounded at one occurrence may not be re-entered, so a `VXU^V04` whose `PV2` arrives before its `PV1` is reported, and no occurrence of a group may begin without the child the publication requires first, so an `ADT^A01` whose `IN2` arrives before its `IN1` is reported as well. The three questions are still kept apart so one defect is reported once: the one bound dropped for a segment name is the one the cardinality check has already reported for it, so a missing or over-repeated segment does not also read as everything after it being out of place. Where two segments arrive in an order the publication does not allow, the finding names **the one that arrived late**: the segment the publication puts first and the message delivered second, which is not always where the reading stopped, because the publication is often free to skip past a segment the message delayed. The pair is found by exchanging adjacent segments and asking whether the publication derives the result, and every adjacent pair is asked rather than a shortlist near the stop: in a message that repeats a group the pair can sit well in front of it, as in an `OML^O21` carrying three orders whose first order's note is delivered after the second order's `ORC`. Where more than one pair would do, the earliest is named.
930
+
931
+ **Read `validated` before `findings`.** A message type the registry does not model, the one retained transcription (`ORM^O01`), a structure with no ordered expectation and a message with no readable type each come back `validated: false` with a `reason` and no findings: "the publication cannot answer" is a different answer from "nothing was wrong". Where the publication splits a structure into variants, conforming to **one** variant conforms to the family, so findings appear only when every variant is violated and they are exactly one named variant's.
932
+
933
+ > **Zero findings is not an attestation.** It means this message did not break the published structure in the three ways above. Field content, datatypes, value sets and HL7 tables are unchecked, coverage is twelve message codes, and the publication is vendored at a fixed commit. See [`docs-content/spec-notes-structure.md`](docs-content/spec-notes-structure.md).
555
934
 
556
935
  ### Validate against your own conformance profile (`validateAgainstProfile`)
557
936
 
@@ -581,7 +960,20 @@ for (const f of findings) console.log(f.severity, f.code, f.message);
581
960
  // e.g. "error PROFILE_VALUE_NOT_IN_SET PID-8 component 1 value is not in the profile value set (3 permitted codes)."
582
961
  ```
583
962
 
584
- The engine holds four invariants: it **never throws** (a malformed profile yields `PROFILE_MALFORMED` findings, not an exception: use `defineConformanceProfile` for a fail-fast authoring gate that _does_ throw); a **valid message yields zero findings**; **no finding carries PHI**: each names the structural locus (segment / field / component / repetition) and the rule, never the offending value; and validation is **read-only**. Usage codes are the HL7 six (`R`/`RE`/`C`/`CE`/`O`/`X`); `C`/`CE` presence is not evaluated (no predicate language, a documented boundary). Distinct from the parse-profile system (`defineProfile`/`profiles`), which shapes _how a message is parsed_.
963
+ The engine holds four invariants: it **never throws** (a malformed profile yields `PROFILE_MALFORMED` findings, not an exception: use `defineConformanceProfile` for a fail-fast authoring gate that _does_ throw); a **valid message yields zero findings**; **no finding carries PHI**: each names the structural locus (segment / field / component / repetition) and the rule, never the offending value; and validation is **read-only**. Distinct from the parse-profile system (`defineProfile`/`profiles`), which shapes _how a message is parsed_.
964
+
965
+ A rule's usage is one of `R`/`RE`/`C`/`CE`/`O`/`X`/`B`, or a declared conditional written `C(t/f)`. A conditionally-used rule can carry a **condition predicate** that the engine evaluates against the message to decide which outcome applies: a location in the same message, a verb from the closed set (`is`, `is not`, `contains`, `does not contain`, `matches`, `does not match`) or a presence statement (`is valued`, `is not valued`), a value list _you_ supply, and `AND`/`OR`/`XOR` connectors.
966
+
967
+ ```ts
968
+ // "PID-8 is Required when a date of birth was sent, Not-permitted when it was not."
969
+ {
970
+ field: 8,
971
+ usage: "C(R/X)",
972
+ condition: { location: { segment: "PID", field: 7 }, presence: "is valued" },
973
+ }
974
+ ```
975
+
976
+ Still no network call and still no code set: a predicate reads the message in front of it and compares against values you wrote down. A predicate the message cannot decide (a comparison against an element that is not there) yields a `PROFILE_CONDITION_UNEVALUATABLE` finding and the element's presence is **not** assessed, rather than being guessed either way. A rule that declares no predicate can still take its outcome from a caller-supplied resolution, and declaring both at one locus is refused.
585
977
 
586
978
  > **"No findings" is not an attestation.** An empty result means _nothing this profile checked was violated_: never "this message is conformant." The profile only covers what you declared, and hl7 makes no conformance certification. See [`docs-content/spec-notes-conformance.md`](docs-content/spec-notes-conformance.md).
587
979
 
@@ -614,7 +1006,7 @@ Profiles are the growth loop. Built-ins cover the common vendor patterns; real i
614
1006
 
615
1007
  ### Authoring a profile
616
1008
 
617
- `defineProfile({ name, customSegments, dateFormats, onWarning, description })` returns a frozen `Profile` object. `customSegments` is a record mapping segment name -> `{ fields: { aliasName: 1-indexedPosition } }`.
1009
+ `defineProfile({ name, customSegments, segmentOverrides, dateFormats, onWarning, description })` returns a frozen `Profile` object. `customSegments` is a record mapping Z-segment name -> `{ fields: { aliasName: 1-indexedPosition } }`; `segmentOverrides` is the same shape for STANDARD segment names (see [Naming fields on standard segments](#naming-fields-on-standard-segments)).
618
1010
 
619
1011
  ```ts
620
1012
  import { defineProfile, type CustomSegmentDefinition } from "@cosyte/hl7";
@@ -633,6 +1025,71 @@ const myhospital = defineProfile({
633
1025
 
634
1026
  Invalid input (missing name, malformed Z-segment name, unsupported date tokens, unknown option keys) throws `ProfileDefinitionError` with an actionable message. See [Error Handling](#error-handling).
635
1027
 
1028
+ ### Naming fields on standard segments
1029
+
1030
+ `customSegments` names fields on your Z-segments. `segmentOverrides` does the same job for the STANDARD segments, for the case every real integration eventually hits: a site that stuffs its own value into a standard field. A second MRN in PID-19, a local severity code in AL1-4, a site dose qualifier in RXA-6. Give it a name once in the profile and read it by that name everywhere, instead of scattering a hand-typed position through your code.
1031
+
1032
+ ```ts
1033
+ import { defineProfile, parseHL7 } from "@cosyte/hl7";
1034
+
1035
+ const site = defineProfile({
1036
+ name: "myhospital-adt",
1037
+ segmentOverrides: {
1038
+ PID: { fields: { siteMrn: 19 } },
1039
+ AL1: { fields: { localSeverityCode: 4 } },
1040
+ RXA: { fields: { doseQualifier: 6 } },
1041
+ },
1042
+ });
1043
+
1044
+ const msg = parseHL7(raw, site);
1045
+ msg.part("PID")?.get("siteMrn")?.value; // the value at PID-19
1046
+ msg.part("AL1")?.get("localSeverityCode")?.value; // the value at AL1-4
1047
+ ```
1048
+
1049
+ **Positions are 1-indexed HL7 positions**, identical in meaning to the argument of `seg.field(n)`, including the MSH numbering convention: a binding at `1` is MSH-1 (the field separator) and a binding at `3` is MSH-3 (the sending application). `get(name)` and `field(position)` always agree.
1050
+
1051
+ **A name that is not declared, and a declared position the message did not carry, both read `undefined`**, never an empty string. A typo surfaces instead of quietly reading blank. The HL7 explicit null (`""`) is a value the sender chose to send, so it still comes back as a `Field`.
1052
+
1053
+ **Inheritance follows the same rule as `customSegments`.** Layer profiles with `extends` and, per segment type, the child's binding wins at any POSITION both declare while the parent's bindings at other positions survive:
1054
+
1055
+ ```ts
1056
+ const parent = defineProfile({
1057
+ name: "parent",
1058
+ segmentOverrides: { PID: { fields: { parentAlias: 19, countyCode: 12 } } },
1059
+ });
1060
+
1061
+ const child = defineProfile({
1062
+ name: "child",
1063
+ extends: parent,
1064
+ segmentOverrides: { PID: { fields: { childAlias: 19 } } },
1065
+ });
1066
+
1067
+ // child.segmentOverrides.PID.fields === { childAlias: 19, countyCode: 12 }
1068
+ ```
1069
+
1070
+ **What `defineProfile()` refuses, at definition time**, throwing `ProfileDefinitionError` and returning no profile:
1071
+
1072
+ | Declaration | Why it is refused |
1073
+ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1074
+ | a Z-segment key (`ZPI`) | that is a custom segment: the error points you at the `customSegments` option |
1075
+ | a name that is not a standard HL7 v2 segment (`QQQ`, `pid`, `PIDX`) | keys are matched against `KNOWN_SEGMENTS` in their canonical uppercase spelling, so a key that would never resolve is rejected rather than silently defining a dead name |
1076
+ | a position that is not a positive integer (`0`, `-1`, `2.5`, `"3"`) | the error names the profile, the segment and the field |
1077
+ | a misspelled option key (`segmentOverride`) | reported with `segmentOverrides` as the suggestion |
1078
+
1079
+ An empty map (`segmentOverrides: {}`), or a declared segment with an empty field map, is accepted and behaves exactly like declaring nothing.
1080
+
1081
+ #### Limitations
1082
+
1083
+ **Overrides add named reads. They change nothing that already reads.** This is the whole design, and it is deliberate: HL7 v2 traffic is PHI, and an alias that re-pointed an existing accessor would hand a downstream consumer a different clinical value with no warning and nothing to notice. Specifically, a `segmentOverrides` declaration does NOT:
1084
+
1085
+ - **re-point the typed clinical accessors.** `msg.patient`, `msg.visit`, `msg.allergies()`, `msg.medications()`, `msg.observations()` and every other typed helper keep reading the positions the HL7 standard assigns. Declaring `siteMrn: 19` does not make `msg.patient?.mrn` read PID-19; it still reads PID-3.
1086
+ - **add a dot-path.** Dot-paths stay positional: `msg.get("PID.19")` works, `msg.get("PID.siteMrn")` is a syntax error. The named accessor is the one route to a declared name.
1087
+ - **change serialization.** `toString()` is byte-for-byte what it was, `toJSON()` is unchanged, and the message still round-trips verbatim.
1088
+ - **change the warning list.** An undeclared Z-segment still emits `UNKNOWN_SEGMENT`, and naming a standard segment in the override map does not suppress or add any warning.
1089
+ - **touch the write path.** `setField`, `setComposite`, `buildMessage` and the typed builders take positions, not names.
1090
+
1091
+ Parse one message with the overrides and again without them and every one of those observables is equal. Only the new names differ.
1092
+
636
1093
  ### Extending profiles
637
1094
 
638
1095
  Use `extends` to layer profiles. Single parent or array of parents, both supported.
@@ -663,6 +1120,7 @@ When `extends` resolves parents, fields are merged per-key with the following ru
663
1120
  - **Scalars** (`description`): later layers overwrite earlier ones.
664
1121
  - **Arrays** (`dateFormats`, `lineage`): concatenate + dedupe, preserving first-seen order.
665
1122
  - **`customSegments` map**: deep-merge per key; same-segment-name in two parents reconciles positional fields.
1123
+ - **`segmentOverrides` map**: the same reducer, on the standard-segment declarations. See [Naming fields on standard segments](#naming-fields-on-standard-segments).
666
1124
  - **`onWarning` handlers**: compose into a chain, invoked in lineage order (parents before children). Errors thrown by one handler do not stop subsequent handlers.
667
1125
  - **`name`**: never inherited, always the profile's own.
668
1126
 
@@ -701,7 +1159,7 @@ Eight profiles ship in the box, reachable via the `profiles` namespace:
701
1159
  - `profiles.athena`: athenahealth. Ambulatory-oriented, ISO-leaning date formats.
702
1160
  - `profiles.genericLab`: Generic reference-lab (LabCorp / Quest-style). Adds `YYYYMMDD HHmm` (ASTM-era) and `YYYY-MM-DD` (ISO date-only); declares `ZLB` (lab overrides) and `ZNT` (lab note) Z-segments.
703
1161
  - `profiles.visage`: Visage 7 imaging/PACS RIS feeds. Declares the `ZDS` Z-segment that carries the DICOM **Study Instance UID** (field 1) so an HL7 order correlates to its DICOM study, the IHE Radiology RIS/PACS bridge segment. Grounded in the public [Visage 7 HL7 Interface Specification](https://www.visageimaging.com/downloads/Visage7/Visage7_HL7InterfaceSpecification.pdf) (V23.00, Jun 2026). Dates are HL7-native, so it adds no date formats.
704
- - `profiles.philips`: Philips Vue PACS ("IS Link") imaging feeds. Declares six Vue PACS Z-segments, one per filler role: `ZDS` (DICOM Study Instance UID), `ZLK` (linked studies/orders), `ZAO` (order additional details: modality, body part, transfer/acquisition status, technician + radiologist), `ZEB` (encrypted patient info), `ZAP` (patient additional details), and `ZAV` (visit additional details). Grounded in the public [Vue PACS 12.2.8 HL7 Interface Specifications](https://www.documents.philips.com/assets/Conformance%20Statements/20240409/8941f89d89aa4983aab7b14d00db578c.pdf) (Philips, doc HA1669 Rev A, §§5.11–5.16). Dates are HL7-native, so it adds no date formats.
1162
+ - `profiles.philips`: Philips Vue PACS ("IS Link") imaging feeds. Declares six Vue PACS Z-segments, one per filler role: `ZDS` (DICOM Study Instance UID), `ZLK` (linked studies/orders), `ZAO` (order additional details: modality, body part, transfer/acquisition status, technician + radiologist), `ZEB` (encrypted patient info), `ZAP` (patient additional details), and `ZAV` (visit additional details). Grounded in the public [Vue PACS 12.2.8 HL7 Interface Specifications](https://www.documents.philips.com/assets/Conformance%20Statements/20240409/8941f89d89aa4983aab7b14d00db578c.pdf) (Philips, doc HA1669 Rev A, §§5.11-5.16). Dates are HL7-native, so it adds no date formats.
705
1163
  - `profiles.va`: U.S. Department of Veterans Affairs VistA Radiology/Nuclear Medicine feeds (HL7 v2.4). Declares the `ZDS` Z-segment that carries the DICOM **Study Instance UID** (field 1): the same IHE Radiology RIS↔PACS bridge as `visage`/`philips`, grounded here in a distinct **federal** spec and documented on **both ORM and ORU** (result) messages. Grounded in the public [Radiology/Nuclear Medicine 5.0 HL7 Interface Specification](https://www.va.gov/vdl/documents/clinical/radiology_nuclear_med/ra5_0hl7is.pdf) (Version 3.6, Patch RA\*5.0\*203, June 2024). Dates are HL7-native, so it adds no date formats.
706
1164
 
707
1165
  Use a built-in directly (`parseHL7(raw, profiles.epic)`) or as a base for your own (`defineProfile({ extends: profiles.epic, ... })`).
@@ -732,7 +1190,7 @@ for (const w of msg.warnings) {
732
1190
  }
733
1191
  ```
734
1192
 
735
- The full list of Tier-2 codes (16 entries: `MLLP_FRAMING_STRIPPED`, `FIELD_WHITESPACE_TRIMMED`, `UNKNOWN_ESCAPE_SEQUENCE`, `TIMESTAMP_FALLBACK_FORMAT`, `SEGMENT_CASE`, `EXTRA_FIELDS`, `UNKNOWN_SEGMENT`, `DUPLICATE_REQUIRED_SEGMENT`, `ENCODING_MISMATCH`, `MISSING_REQUIRED_FIELD`, `MISSING_EXPECTED_GROUP`, `OUT_OF_ORDER_SEGMENT`, `VERSION_MISMATCH`, `UNKNOWN_CHARSET`, `ACK_NO_CORRELATION_ID`, `MERGE_MISSING_PRIOR_OR_SURVIVOR`) lives in [`src/parser/warnings.ts`](./src/parser/warnings.ts). Narrow on `w.code === WARNING_CODES.UNKNOWN_SEGMENT` (and friends) for typo-free comparisons.
1193
+ The full list of Tier-2 codes lives in [`src/parser/warnings.ts`](./src/parser/warnings.ts). Narrow on `w.code === WARNING_CODES.UNKNOWN_SEGMENT` (and friends) for typo-free comparisons.
736
1194
 
737
1195
  Need zero tolerance instead? `parseHL7(raw, { strict: true })` escalates every Tier-2 deviation to a thrown `Hl7ParseError`. Use strict in CI validators; leave it off for production ingestion.
738
1196
 
@@ -832,19 +1290,15 @@ All three error types (and the `FATAL_CODES` / `WARNING_CODES` registries, and t
832
1290
 
833
1291
  ## Roadmap
834
1292
 
835
- Not in v1, but on the roadmap for v2:
1293
+ Nothing is queued for a future release. Three capabilities a reader might expect to find named here are available today. Batch files too large to buffer are parsed incrementally by [`parseStream`](#streaming-large-files-parsestream), which yields the messages as an async iterable. JSON Schema and Zod descriptions of what `toJSON()` returns come from `emitMessageSchema`; Zod arrives as TypeScript source text to paste into your own project, so this package still has zero runtime dependencies (see the [schema emission notes](./docs-content/schema-emission.md)). A profile's declared field names are carried into the type system, so `seg.get("departmentCode")` on a declared segment is checked against that profile's names instead of widening to `string | undefined`. All three are exported, and all three are covered by the stability claim in [Status](#status).
836
1294
 
837
- - **Typed message overlays** (`msg.is("ADT^A01")` narrows to `AdtA01Message`): message-type-aware getter narrowing.
838
- - **Schema-aware structure validation**: opt-in structural validator that enforces segment ordering + cardinality against the HL7 spec.
839
- - **Streaming parser for large batch files**: `createHL7Stream()` returning an iterable of messages, for multi-GB batch processing.
840
- - **JSON Schema / Zod emission for `toJSON()` output**: autogenerated schemas from the internal typed model.
841
- - **Type-safe custom-segment field names via conditional types**: `seg.get("departmentCode")` narrows to the profile's declared field alias, not `string | undefined`.
1295
+ What lands next starts as a quirk sighting or a real-world edge case on the [issue tracker](https://github.com/cosyte/hl7/issues).
842
1296
 
843
1297
  ### Out of scope (permanently)
844
1298
 
845
- - **MLLP framing / network transport**: a future `@cosyte/hl7-mllp` package will cover network IO; this library is parser-only.
1299
+ - **Network transport**: sending and receiving over MLLP is [`@cosyte/mllp`](https://github.com/cosyte/mllp)'s job; this library is parser-only and only strips MLLP framing on read.
846
1300
  - **HL7 v3 and CDA**: different spec family entirely; not in our plans.
847
- - **FHIR conversion**: a future companion package could bridge, but the conversion is non-trivial and deserves its own repo.
1301
+ - **FHIR conversion**: [`@cosyte/transform`](https://github.com/cosyte/transform) converts a parsed message to FHIR R4; this library does not.
848
1302
  - **Exhaustive coded-value validation**: we validate structure, not every HL7 table. Integrate a domain validator (e.g. your LIS's internal code registry) for that.
849
1303
 
850
1304
  ---
@@ -853,18 +1307,20 @@ Not in v1, but on the roadmap for v2:
853
1307
 
854
1308
  Vendor-quirk fixtures, profile improvements, and standalone profile packages are all welcome. The more real-world edge cases the test suite covers, the more robust the parser gets. Every published profile package is a signal of adoption and a contribution back.
855
1309
 
1310
+ Ask on the issue tracker, [github.com/cosyte/hl7/issues](https://github.com/cosyte/hl7/issues): questions, bug reports and quirk sightings all start there, and so does any refactor large enough to want discussion before a PR.
1311
+
856
1312
  See [CONTRIBUTING.md](./CONTRIBUTING.md) for dev setup, how to file an issue, and how to submit a PR.
857
1313
 
858
1314
  ---
859
1315
 
860
1316
  ## Trademarks
861
1317
 
862
- Epic, Cerner, MEDITECH, athenahealth, LabCorp, Quest Diagnostics, Visage (Visage Imaging), and Philips (Vue PACS) are trademarks of their respective owners. cosyte is not affiliated with, endorsed by, or
1318
+ Epic, Cerner, MEDITECH, athenahealth, LabCorp, Quest Diagnostics, Visage (Visage Imaging), and Philips (Vue PACS) are trademarks of their respective owners. Cosyte is not affiliated with, endorsed by, or
863
1319
  sponsored by any of them: the names identify the systems whose real-world message quirks the built-in profiles accommodate. See [TRADEMARKS.md](./TRADEMARKS.md).
864
1320
 
865
1321
  ## License
866
1322
 
867
- MIT: see [LICENSE](./LICENSE).
1323
+ MIT, copyright Cosyte. See [LICENSE](./LICENSE).
868
1324
 
869
1325
  ---
870
1326