@cosyte/hl7 0.0.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 ADDED
@@ -0,0 +1,795 @@
1
+ # @cosyte/hl7
2
+
3
+ > Parse real-world, vendor-quirky HL7 v2 messages and extract the fields you need in one line — without reading the spec.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@cosyte/hl7.svg)](https://www.npmjs.com/package/@cosyte/hl7)
6
+ [![CI](https://img.shields.io/github/actions/workflow/status/cosyte/hl7/ci.yml?branch=main&label=CI)](https://github.com/cosyte/hl7/actions/workflows/ci.yml)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
8
+ [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
9
+
10
+ A developer-focused HL7 v2 parser and utility library for Node.js and TypeScript. Optimised for the 10% of HL7 you actually use — with the other 90% still one accessor away when you need it.
11
+
12
+ ---
13
+
14
+ ## Quickstart
15
+
16
+ Three lines of useful output after install + parse. No HL7 spec knowledge required.
17
+
18
+ ```bash
19
+ # pnpm (recommended) — also works with: npm install @cosyte/hl7 | yarn add @cosyte/hl7
20
+ pnpm add @cosyte/hl7
21
+ ```
22
+
23
+ ```ts
24
+ import { parseHL7 } from "@cosyte/hl7";
25
+
26
+ const msg = parseHL7(rawHL7);
27
+ console.log(msg.patient?.fullName); // "John Q. Doe"
28
+ console.log(msg.patient?.mrn); // "MRN12345"
29
+ console.log(msg.meta.timestamp?.raw); // "20260419101500" (fidelity TS — precision + zone preserved)
30
+ ```
31
+
32
+ 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.
33
+
34
+ ---
35
+
36
+ ## Features
37
+
38
+ - **One-line extraction** — `msg.patient.mrn`, `msg.meta.timestamp`, `msg.observations()`, and friends. No segment or field numbers to memorise.
39
+ - **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.
40
+ - **Real-world tolerance, four-tier** — lenient default parses vendor-quirky messages; 18 stable warning codes flag what was tolerated; strict mode escalates every deviation for CI validators; only 4 truly-structural failures are fatal.
41
+ - **First-class profile system** — `defineProfile()` API, 5 built-in vendor profiles (Epic, Cerner, Meditech, athenahealth, generic lab), plus a [publishable starter kit](./examples/profile-starter-kit/) you copy-and-ship.
42
+ - **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).
43
+ - **Strict TypeScript, zero runtime deps** — ES2022, `noUncheckedIndexedAccess`, dual ESM + CJS, Node 18+. Every public export has JSDoc + `@example` that feeds your editor's IntelliSense.
44
+ - **Warnings carry stable codes + positional context** — react programmatically by `w.code`, with `segmentIndex`/`fieldIndex`/etc. attached.
45
+
46
+ ---
47
+
48
+ ## HL7 in 90 seconds
49
+
50
+ HL7 v2 is a pipe-delimited messaging format used across US healthcare. A message is a sequence of line-oriented **segments** (3-letter names like `MSH`, `PID`, `OBX`). Each segment carries **fields** separated by `|`; fields decompose into **components** (`^`) and **subcomponents** (`&`). Repeating fields use `~`.
51
+
52
+ A typical ADT message looks like this:
53
+
54
+ ```
55
+ Message
56
+ ├── MSH (header — sender, receiver, type, timestamp)
57
+ ├── EVN (event details for ADT)
58
+ ├── PID (patient identification — name, MRN, DOB)
59
+ ├── PV1 (visit — class, location, attending)
60
+ └── OBX×N (observations — repeats for labs, vitals)
61
+
62
+ Each segment = pipe-delimited (|) fields
63
+ Each field = caret-delimited (^) components
64
+ Each component = ampersand-delimited (&) subcomponents
65
+ ```
66
+
67
+ That's enough HL7 to use this library productively. Everything else is in the spec, but you won't need it.
68
+
69
+ ---
70
+
71
+ ## Access patterns
72
+
73
+ Three ways to reach into a parsed message, each optimised for a different use case.
74
+
75
+ ### Named helpers
76
+
77
+ Typed views over the common-case data. Zero HL7 literacy required.
78
+
79
+ ```ts
80
+ import { parseHL7 } from "@cosyte/hl7";
81
+
82
+ const msg = parseHL7(raw);
83
+
84
+ console.log(msg.patient?.mrn); // "MRN12345"
85
+ console.log(msg.patient?.fullName); // "Jane Q. Smith"
86
+ console.log(msg.patient?.dateOfBirth?.precision); // "day" — a birth date is not a full instant
87
+ console.log(msg.meta.type); // "ADT^A01"
88
+ console.log(msg.meta.timestamp?.raw); // "20250102153045" (MSH-7 — fidelity TS)
89
+ console.log(msg.visit?.location); // PL composite — pointOfCare/room/bed
90
+ ```
91
+
92
+ Use these for the 90% case. Helpers return `undefined` (not throws) when the underlying segment is absent, so optional-chaining stays idiomatic.
93
+
94
+ ### Dot-paths
95
+
96
+ String paths following HL7's own `SEG.field.component.subcomponent` convention. Indices are 1-based to match the spec.
97
+
98
+ ```ts
99
+ import { parseHL7 } from "@cosyte/hl7";
100
+
101
+ const msg = parseHL7(raw);
102
+
103
+ console.log(msg.get("PID.5.1")); // family name -> "Smith"
104
+ console.log(msg.get("PID.5.2")); // given name -> "Jane"
105
+ console.log(msg.get("OBX[2].5")); // 2nd OBX, value field
106
+ console.log(msg.getAll("NK1")); // every next-of-kin segment
107
+ ```
108
+
109
+ Reach for dot-paths when you want a specific field the helpers don't surface — or when you're scripting quick extractions without introducing typed imports.
110
+
111
+ ### Structural traversal
112
+
113
+ Walk the segment/field/component/subcomponent tree directly. For advanced extractors, round-trip edits, and anything the other two patterns don't cover.
114
+
115
+ ```ts
116
+ import { parseHL7 } from "@cosyte/hl7";
117
+
118
+ const msg = parseHL7(raw);
119
+
120
+ for (const obx of msg.segments("OBX")) {
121
+ const id = obx.field(3).component(1).value; // observation identifier
122
+ const val = obx.field(5).value; // observed value
123
+ console.log(`${id} = ${val}`);
124
+ }
125
+
126
+ console.log(msg.allSegments().length); // total segments in the message
127
+ ```
128
+
129
+ Every level (segment / field / repetition / component / subcomponent) is addressable and immutable — mutation happens through explicit methods (see the cookbook).
130
+
131
+ ---
132
+
133
+ ## Cookbook
134
+
135
+ Runnable recipes for the tasks developers hit most often. Every snippet imports from `@cosyte/hl7` and uses APIs exported from [`src/index.ts`](./src/index.ts). Keep them short; lean on the named-helper surface first.
136
+
137
+ ### Patient demographics
138
+
139
+ Reach for the `msg.patient` helper for the name/MRN/DOB trio that covers 95% of demographic extractions.
140
+
141
+ ```ts
142
+ import { parseHL7 } from "@cosyte/hl7";
143
+
144
+ const msg = parseHL7(raw);
145
+ const p = msg.patient;
146
+
147
+ console.log(p?.mrn); // "MRN12345"
148
+ console.log(p?.fullName); // "Jane Q. Smith"
149
+ console.log(p?.dateOfBirth?.raw); // "19800115" (PID-7 — fidelity TS at day precision)
150
+ console.log(p?.sex); // "F"
151
+ console.log(p?.address?.city); // XAD composite
152
+ ```
153
+
154
+ `msg.patient` is `undefined` when the message has no `PID` segment — use `?.` consistently. The `address` field is an `XAD` composite with `streetAddress`, `city`, `state`, `zip`, `country`. `dateOfBirth` (and every datetime) is a **fidelity `TS`** — see [Datetime precision & timezone fidelity](#datetime-precision--timezone-fidelity).
155
+
156
+ ### Lab results
157
+
158
+ Iterate `msg.observations()` for the flat-list view of every `OBX` segment, regardless of whether they're grouped under an `OBR`.
159
+
160
+ ```ts
161
+ import { parseHL7 } from "@cosyte/hl7";
162
+
163
+ const msg = parseHL7(raw);
164
+
165
+ for (const obs of msg.observations()) {
166
+ console.log(obs.identifier.text, obs.value, obs.units?.identifier);
167
+ // "Glucose" 120 "mg/dL"
168
+ }
169
+ ```
170
+
171
+ Observations are discriminated by `valueType` (`"NM"` -> number, `"TS"`/`"DT"` -> fidelity `TS`, `"CWE"`/`"CE"` -> composite, everything else -> string). Use `msg.orders()` instead when you need OBR -> OBX grouping for lab order processing.
172
+
173
+ ### Admit location
174
+
175
+ The `msg.visit` helper surfaces `PV1` data including the `PL` (Person Location) composite — ward / room / bed / facility in one go.
176
+
177
+ ```ts
178
+ import { parseHL7 } from "@cosyte/hl7";
179
+
180
+ const msg = parseHL7(raw);
181
+ const loc = msg.visit?.location;
182
+
183
+ console.log(loc?.pointOfCare); // "ICU"
184
+ console.log(loc?.room); // "101"
185
+ console.log(loc?.bed); // "A"
186
+ console.log(loc?.facility?.namespaceId); // "MAIN"
187
+ ```
188
+
189
+ `msg.visit` is `undefined` when the message has no `PV1` segment (most `ORU`/`ORM` messages). The `attendingDoctor` and `referringDoctor` fields are `XCN` composites.
190
+
191
+ ### Modify and reserialize
192
+
193
+ `setField` mutates the positional tree in-place; `toString()` emits spec-clean HL7 (Postel's Law). See [`examples/modify-and-resend.ts`](./examples/modify-and-resend.ts) for the end-to-end script.
194
+
195
+ ```ts
196
+ import { parseHL7 } from "@cosyte/hl7";
197
+
198
+ const msg = parseHL7(raw);
199
+
200
+ console.log(msg.get("PV1.3.1")); // original ward
201
+ msg.setField("PV1.3.1", "NEW-WARD");
202
+ console.log(msg.get("PV1.3.1")); // "NEW-WARD"
203
+
204
+ const outbound = msg.toString(); // spec-clean HL7 wire format
205
+ ```
206
+
207
+ `setField` takes the same dot-path syntax as `get`; `addSegment(name, fields)` and `removeSegment(...)` round out the mutation surface. The emitter always uses canonical delimiters (`|`, `^`, `~`, `\`, `&`) regardless of what the input used.
208
+
209
+ ### Allergies
210
+
211
+ `msg.allergies()` walks every `AL1` segment and returns a typed list.
212
+
213
+ ```ts
214
+ import { parseHL7 } from "@cosyte/hl7";
215
+
216
+ const msg = parseHL7(raw);
217
+
218
+ for (const al of msg.allergies()) {
219
+ console.log(al.code?.text, al.severity, al.reaction);
220
+ // "Penicillin" "SV" "Hives"
221
+ }
222
+ ```
223
+
224
+ 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()`).
225
+
226
+ ### Scheduling, documents & charges (SIU · MDM · DFT)
227
+
228
+ Three breadth helpers cover the scheduling, medical-record, and financial message families:
229
+
230
+ ```ts
231
+ import { parseHL7 } from "@cosyte/hl7";
232
+
233
+ // SIU — appointments(): SCH id/status/timing + AIS/AIG/AIL/AIP resources
234
+ for (const appt of parseHL7(raw).appointments()) {
235
+ console.log(appt.fillerAppointmentId, appt.fillerStatusCode?.identifier); // status verbatim
236
+ const provider = appt.resources.find((r) => r.kind === "personnel");
237
+ console.log(provider?.person?.familyName);
238
+ }
239
+
240
+ // MDM — documents(): completion (TXA-17) and availability (TXA-19) are DISTINCT
241
+ for (const doc of parseHL7(raw).documents()) {
242
+ console.log(doc.completionStatus, doc.availabilityStatus); // e.g. "IP" (in progress) + "AV" (available)
243
+ // never conflated — a document can be AVAILABLE before it is AUTHENTICATED;
244
+ // reading a preliminary document as final is the harm this split prevents.
245
+ }
246
+
247
+ // DFT — charges(): FT1 billing fields, amounts kept as canonical wire text (no money-as-float)
248
+ for (const charge of parseHL7(raw).charges()) {
249
+ console.log(charge.transactionType, charge.transactionCode?.identifier);
250
+ console.log(charge.amountExtended); // e.g. "150.00^USD" — a string, never a number
251
+ }
252
+ ```
253
+
254
+ These are **breadth** helpers — they surface the common trigger events' core fields, not a scheduling-workflow state machine, signature verification, or a claims/pricing engine (see the roadmap's known-limitations).
255
+
256
+ ### Order & medication timing
257
+
258
+ Every `Order` (from `msg.orders()`) and `Medication` (from `msg.medications()`) carries a `timings` array — the `TQ1` segment (HL7 v2.5+) or the legacy embedded TQ in `ORC-7` / `RXE-1` (pre-v2.5).
259
+
260
+ ```ts
261
+ import { parseHL7 } from "@cosyte/hl7";
262
+
263
+ const med = parseHL7(raw).medications()[0];
264
+
265
+ for (const t of med?.timings ?? []) {
266
+ console.log(t.source); // "TQ1" or "legacy"
267
+ console.log(t.repeatPattern?.code); // "Q6H" — VERBATIM, never resolved to a schedule
268
+ console.log(t.repeatPattern?.kind); // "parametric" (provenance only)
269
+ console.log(t.repeatPattern?.interval); // { count: 6, unit: "H" } — the load-bearing integer
270
+ console.log(t.totalOccurrences); // 24 — from TQ1-14 (not TQ1-11)
271
+ console.log(t.startDateTime?.raw, t.startDateTime?.precision);
272
+ }
273
+ ```
274
+
275
+ The repeat pattern (Table 0335) is surfaced **verbatim** — hl7 never normalizes a sig, resolves `Q6H` to clock times, or maps it to a different frequency (reading `Q6H` as "daily", or losing a `BID`, changes the administered dose count). The `kind` flag (`parametric`/`named`/`unknown`) is provenance only and never drives a schedule; a parametric `Q<n><unit>` template's integer is never dropped. See `docs-content/spec-notes-timing.md` for the field map and non-goals.
276
+
277
+ ### Patient merges and identity events
278
+
279
+ `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) — and 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.
280
+
281
+ ```ts
282
+ import { parseHL7 } from "@cosyte/hl7";
283
+
284
+ const msg = parseHL7(rawA40);
285
+
286
+ for (const ev of msg.identityEvents()) {
287
+ if (ev.kind === "merge" && ev.prior && ev.surviving && ev.warnings.length === 0) {
288
+ // retire ev.prior.identifiers in favour of ev.surviving.identifiers
289
+ console.log(ev.prior.identifiers[0]?.idNumber, "->", ev.surviving.identifiers[0]?.idNumber);
290
+ } else if (ev.warnings.length > 0) {
291
+ // incomplete MRG->PID pair (MERGE_MISSING_PRIOR_OR_SURVIVOR) — route for review
292
+ }
293
+ }
294
+ ```
295
+
296
+ 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.
297
+
298
+ ### Write your first profile in 10 minutes
299
+
300
+ A profile is plain data: a name, optional extra `dateFormats`, and a `customSegments` map of Z-segment declarations. The record shape maps a caller-visible field name to its 1-indexed HL7 position.
301
+
302
+ ```ts
303
+ import { defineProfile, parseHL7 } from "@cosyte/hl7";
304
+
305
+ const myProfile = defineProfile({
306
+ name: "myhospital",
307
+ description: "Custom Z-segments for our internal integration",
308
+ customSegments: {
309
+ ZAL: { fields: { allergyId: 1, severity: 2, verifiedAt: 3 } },
310
+ },
311
+ });
312
+
313
+ const msg = parseHL7(raw, myProfile);
314
+ const zal = msg.allSegments().find((s) => s.type === "ZAL");
315
+ console.log(zal?.get("severity")?.value); // "HIGH"
316
+ ```
317
+
318
+ Pass the profile as the second argument to `parseHL7`; afterwards, `seg.get("fieldName")` resolves through the profile's field aliases. `UNKNOWN_SEGMENT` warnings for declared Z-segments are suppressed automatically.
319
+
320
+ ### Extending a profile
321
+
322
+ `extends` layers one profile on top of another. Use it to add hospital-specific Z-segments on top of a vendor baseline.
323
+
324
+ ```ts
325
+ import { defineProfile, profiles, parseHL7 } from "@cosyte/hl7";
326
+
327
+ const myEpic = defineProfile({
328
+ name: "my-epic",
329
+ extends: profiles.epic,
330
+ customSegments: {
331
+ ZPR: { fields: { providerId: 1, specialty: 2 } },
332
+ },
333
+ });
334
+
335
+ const msg = parseHL7(raw, myEpic);
336
+ console.log(msg.profile?.lineage); // ["epic", "my-epic"]
337
+ ```
338
+
339
+ Date formats concatenate (deduped), `customSegments` deep-merge per key, `onWarning` handlers chain in lineage order. The parent's `describe()` surface is preserved so `myEpic.describe()` lists both layers.
340
+
341
+ ### Composing profiles
342
+
343
+ Pass an array to `extends` to merge multiple parents. Useful when combining a vendor profile with a market-specific overlay (e.g. Epic + reference-lab conventions).
344
+
345
+ ```ts
346
+ import { defineProfile, profiles, parseHL7 } from "@cosyte/hl7";
347
+
348
+ const combined = defineProfile({
349
+ name: "epic-plus-lab",
350
+ extends: [profiles.epic, profiles.genericLab],
351
+ dateFormats: ["YYYY-MM-DD HH:mm"],
352
+ });
353
+
354
+ const msg = parseHL7(raw, combined);
355
+ console.log(msg.profile?.lineage); // ["epic", "genericLab", "epic-plus-lab"]
356
+ ```
357
+
358
+ Conflicts on scalar keys (later wins), arrays concatenate+dedupe, `customSegments` maps merge. See [Merge semantics](#merge-semantics) in the Profiles section for the full rules.
359
+
360
+ ### Publishing a profile package
361
+
362
+ Real production specs live at the integration level — specific EHR instances, reference labs, HIEs. The [profile starter kit](./examples/profile-starter-kit/) is a copy-and-customise template: it ships publishable as-is, with placeholders you replace with your org/profile names.
363
+
364
+ ```bash
365
+ cp -r examples/profile-starter-kit my-profile && cd my-profile
366
+ # Find/replace {{YOUR_ORG}} and {{PROFILE_NAME}}, then:
367
+ pnpm install
368
+ pnpm test
369
+ pnpm build
370
+ pnpm publish --access public
371
+ ```
372
+
373
+ See [`examples/profile-starter-kit/CUSTOMIZING.md`](./examples/profile-starter-kit/CUSTOMIZING.md) for the 5-step walkthrough (rename, swap base profile, define Z-segments, write fixtures, publish). The kit includes CI + publish workflows, a sample profile that demonstrates every feature, and zero-threshold Vitest so you ship green-or-red without fighting coverage gates.
374
+
375
+ ### Default profile
376
+
377
+ `setDefaultProfile` registers a profile for every subsequent `parseHL7(...)` call in the current Node process. Convenient when every message in your pipeline comes from the same sender; explicit per-call passing is usually clearer.
378
+
379
+ ```ts
380
+ import { parseHL7, profiles, setDefaultProfile, getDefaultProfile } from "@cosyte/hl7";
381
+
382
+ setDefaultProfile(profiles.epic);
383
+ console.log(getDefaultProfile()?.name); // "epic"
384
+
385
+ const msg = parseHL7(raw); // uses epic implicitly
386
+ const bare = parseHL7(raw, { profile: null }); // opt out for this call
387
+
388
+ setDefaultProfile(null); // reset
389
+ ```
390
+
391
+ The default is scoped to the current Node process — not shared across workers, not serialisable. Opt out for a specific parse with `{ profile: null }` in the options bag.
392
+
393
+ ### Datetime precision & timezone fidelity
394
+
395
+ Every HL7 datetime (TS/DTM) is surfaced as a **fidelity `TS`** — the raw string plus its typed parts,
396
+ with the stated **precision** and **timezone** preserved. It is deliberately **not** an eager JS `Date`:
397
+ a day-only birth date coerced to a UTC-midnight instant reads as the _previous day_ in a negative-offset
398
+ zone. The parser preserves what the sender wrote and lets you decide how to localize.
399
+
400
+ ```ts
401
+ import { parseHL7, dtmToDate } from "@cosyte/hl7";
402
+
403
+ const dob = parseHL7(raw).patient?.dateOfBirth;
404
+ console.log(dob?.raw); // "19880705"
405
+ console.log(dob?.precision); // "day" — never zero-filled to a full timestamp
406
+ console.log(dob?.year, dob?.month, dob?.day); // 1988 7 5 (month is 1-based, spec-native)
407
+ console.log(dob?.hasTimezone); // false — a missing offset is FLAGGED, never assumed to be UTC
408
+
409
+ // An absolute instant is opt-in and honest — it refuses to guess a zone:
410
+ console.log(dtmToDate(dob!)); // undefined (no offset, no assumption)
411
+ console.log(dtmToDate(dob!, { assumeOffsetMinutes: 0 })); // 1988-07-05T00:00:00.000Z (you chose UTC)
412
+
413
+ // A value that carries its own offset resolves exactly:
414
+ const ts = parseHL7(raw).meta.timestamp; // e.g. "20250102153045-0500"
415
+ console.log(ts?.offsetMinutes); // -300
416
+ console.log(dtmToDate(ts!)?.toISOString()); // "2025-01-02T20:30:45.000Z"
417
+ ```
418
+
419
+ hl7 preserves precision + timezone **fidelity**; it does **not** localize, convert, or do arithmetic on
420
+ timestamps — a consumer needing an absolute instant applies the sender's zone via `assumeOffsetMinutes`.
421
+ Use `parseDtm` / `formatDtm` / `dtmToDate` directly on a raw string when you're outside the message model.
422
+
423
+ #### Non-standard timestamp formats
424
+
425
+ HL7's canonical `YYYYMMDDHHmmss` parses with zero warnings. Everything else — vendor-quirky
426
+ `MM/DD/YYYY`, ISO `YYYY-MM-DD`, legacy `YYYYMMDD HHmm` — parses via the `dateFormats` option, which
427
+ tries each format in order and populates the same fidelity `TS`.
428
+
429
+ ```ts
430
+ import { parseHL7 } from "@cosyte/hl7";
431
+
432
+ const msg = parseHL7(raw, {
433
+ dateFormats: ["MM/DD/YYYY HH:mm:ss", "MM/DD/YYYY", "YYYY-MM-DD"],
434
+ });
435
+
436
+ console.log(msg.meta.timestamp?.matchedFormat); // e.g. "MM/DD/YYYY" (which fallback won)
437
+ ```
438
+
439
+ 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.
440
+
441
+ ### Stripping MLLP framing
442
+
443
+ MLLP (Minimum Lower Layer Protocol) wraps HL7 messages in VT / FS / CR bytes for TCP transport. The parser strips them by default and emits a `MLLP_FRAMING_STRIPPED` warning so you know preprocessing happened.
444
+
445
+ ```ts
446
+ import { parseHL7, WARNING_CODES } from "@cosyte/hl7";
447
+
448
+ const msg = parseHL7(mllpFramed);
449
+ const framed = msg.warnings.some((w) => w.code === WARNING_CODES.MLLP_FRAMING_STRIPPED);
450
+ console.log(framed); // true if input had VT/FS/CR framing
451
+
452
+ // Disable preprocessing if your input is guaranteed framing-free:
453
+ parseHL7(raw, { stripMllpFraming: false });
454
+ ```
455
+
456
+ Strip behaviour is idempotent — calling `parseHL7` on already-stripped input is a no-op. The parser does NOT add MLLP framing on `toString()`; if you need it for transport, prepend/append the bytes in your transport layer (a future `@cosyte/hl7-mllp` package will cover network IO end to end).
457
+
458
+ ### Batch files
459
+
460
+ Real lab / ELR / IIS feeds ship many messages wrapped in the HL7 v2 batch envelope (`[FHS] { [BHS] { MSH… } [BTS] } [FTS]`). `splitBatch()` demarcates the individual messages by `MSH` boundary and hands each one back **already parsed** — a malformed message mid-stream is isolated (returned as a typed failure), never suppressing its siblings, and the declared BTS-1 / FTS-1 counts are reconciled without ever dropping the tail.
461
+
462
+ ```ts
463
+ import { splitBatch } from "@cosyte/hl7";
464
+
465
+ const { messages, warnings } = splitBatch(rawBatchFile);
466
+
467
+ for (const entry of messages) {
468
+ if (entry.ok) {
469
+ handle(entry.message); // a fully-parsed Hl7Message
470
+ } else {
471
+ quarantine(entry.raw, entry.error.code); // isolated, not dropped
472
+ }
473
+ }
474
+
475
+ // Batch-level warnings carry counts/positions only — never PHI.
476
+ for (const w of warnings) {
477
+ // BATCH_COUNT_MISMATCH — declared BTS-1/FTS-1 ≠ actual (tail still returned)
478
+ // BATCH_MISSING_TRAILER — a BHS/FHS header with no matching BTS/FTS
479
+ }
480
+ ```
481
+
482
+ A **bare single message** (no envelope) passes straight through as a one-message result. `splitBatch` is a splitter, not an enforcer: it warns on a missing trailer but leaves accept/reject to you (a profile such as IIS may mandate the full frame), does not generate batch ACKs, and does not de-batch across transport framing (that is [`@cosyte/mllp`](https://github.com/cosyte/mllp)). The second argument is forwarded to `parseHL7` per message (profile, `strict`, `charset`). See the [batch spec notes](./docs-content/spec-notes-batch.md) for the full traceability + known limitations.
483
+
484
+ ### Detect message type
485
+
486
+ `msg.meta` exposes MSH-9 pre-decomposed into its three components — use them instead of parsing the raw string.
487
+
488
+ ```ts
489
+ import { parseHL7 } from "@cosyte/hl7";
490
+
491
+ const msg = parseHL7(raw);
492
+
493
+ console.log(msg.meta.type); // "ADT^A01"
494
+ console.log(msg.meta.messageCode); // "ADT"
495
+ console.log(msg.meta.triggerEvent); // "A01"
496
+ console.log(msg.meta.messageStructure); // "ADT_A01" (if present in MSH-9.3)
497
+
498
+ if (msg.meta.messageCode === "ORU") {
499
+ // branch for observation results
500
+ }
501
+ ```
502
+
503
+ 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.
504
+
505
+ ### Spot a truncated or misrouted message
506
+
507
+ 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.
508
+
509
+ ```ts
510
+ import { parseHL7, WARNING_CODES } from "@cosyte/hl7";
511
+
512
+ const msg = parseHL7(raw); // ORU^R01 with only MSH + PID
513
+
514
+ console.log(msg.structure.recognized); // true
515
+ console.log(msg.structure.missingGroups); // ["result"] (no OBR/OBX)
516
+
517
+ // The same finding also surfaces as an additive Tier-2 warning, so a
518
+ // channel can route on it without inspecting `structure`:
519
+ if (msg.warnings.some((w) => w.code === WARNING_CODES.MISSING_EXPECTED_GROUP)) {
520
+ // quarantine / alert: the message is missing an expected segment group
521
+ }
522
+ ```
523
+
524
+ 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.
525
+
526
+ ### Pretty-print for logs
527
+
528
+ `msg.prettyPrint()` returns a multi-line, labeled view of the positional tree — useful for dev-time debugging and log snapshots.
529
+
530
+ ```ts
531
+ import { parseHL7 } from "@cosyte/hl7";
532
+
533
+ const msg = parseHL7(raw);
534
+ console.log(msg.prettyPrint());
535
+ // MSH-1 (field separator): |
536
+ // MSH-2 (encoding chars): ^~\&
537
+ // MSH-3 (sending app): CLINIC
538
+ // ...
539
+ // PID-5 (name):
540
+ // .1 = Smith
541
+ // .2 = Jane
542
+ // ...
543
+ ```
544
+
545
+ Pretty-print is **not** a replacement for `toString()` — it's for humans. Use `toString()` for wire output and `toJSON()` when you need a serialisable structure.
546
+
547
+ ---
548
+
549
+ ## Profiles
550
+
551
+ Profiles are the growth loop. Built-ins cover the common vendor patterns; real integrations (hospital-specific Epic instances, reference labs, HIEs) publish their own profile packages that extend the built-ins. The `defineProfile()` API treats built-ins and user-authored profiles as equal citizens — there's no second-class path.
552
+
553
+ ### Authoring a profile
554
+
555
+ `defineProfile({ name, customSegments, dateFormats, onWarning, description })` returns a frozen `Profile` object. `customSegments` is a record mapping segment name -> `{ fields: { aliasName: 1-indexedPosition } }`.
556
+
557
+ ```ts
558
+ import { defineProfile, type CustomSegmentDefinition } from "@cosyte/hl7";
559
+
560
+ const zdp: CustomSegmentDefinition = {
561
+ fields: { departmentCode: 3, departmentName: 4 },
562
+ };
563
+
564
+ const myhospital = defineProfile({
565
+ name: "myhospital",
566
+ description: "Custom Z-segments for MyHospital ADT feed",
567
+ dateFormats: ["MM/DD/YYYY HH:mm:ss"],
568
+ customSegments: { ZDP: zdp },
569
+ });
570
+ ```
571
+
572
+ 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).
573
+
574
+ ### Extending profiles
575
+
576
+ Use `extends` to layer profiles. Single parent or array of parents, both supported.
577
+
578
+ ```ts
579
+ import { defineProfile, profiles } from "@cosyte/hl7";
580
+
581
+ const myEpic = defineProfile({
582
+ name: "my-epic",
583
+ extends: profiles.epic,
584
+ customSegments: {
585
+ ZPR: { fields: { providerId: 1 } },
586
+ },
587
+ });
588
+
589
+ const combined = defineProfile({
590
+ name: "epic-plus-lab",
591
+ extends: [profiles.epic, profiles.genericLab],
592
+ });
593
+ ```
594
+
595
+ Lineage is recorded on the result (`profile.lineage`) in parent-first order, ending with the profile's own name.
596
+
597
+ ### Merge semantics
598
+
599
+ When `extends` resolves parents, fields are merged per-key with the following rules:
600
+
601
+ - **Scalars** (`description`): later layers overwrite earlier ones.
602
+ - **Arrays** (`dateFormats`, `lineage`): concatenate + dedupe, preserving first-seen order.
603
+ - **`customSegments` map**: deep-merge per key; same-segment-name in two parents reconciles positional fields.
604
+ - **`onWarning` handlers**: compose into a chain, invoked in lineage order (parents before children). Errors thrown by one handler do not stop subsequent handlers.
605
+ - **`name`**: never inherited — always the profile's own.
606
+
607
+ The merge is validated post-hoc: duplicate field names across merged segments throw `ProfileDefinitionError` up front, not at parse time.
608
+
609
+ ### Inspecting a profile
610
+
611
+ Every `defineProfile()` result carries a `.describe()` method and an introspectable `lineage` array — useful for debugging which layers contributed what.
612
+
613
+ ```ts
614
+ import { defineProfile, profiles } from "@cosyte/hl7";
615
+
616
+ const p = defineProfile({
617
+ name: "my-epic",
618
+ extends: profiles.epic,
619
+ customSegments: { ZPR: { fields: { providerId: 1 } } },
620
+ });
621
+
622
+ console.log(p.name); // "my-epic"
623
+ console.log(p.lineage); // ["epic", "my-epic"]
624
+ console.log(Object.keys(p.customSegments ?? {})); // ["ZDP", "ZRS", "ZPR"]
625
+ console.log(p.describe?.()); // multi-line summary
626
+ ```
627
+
628
+ ### Publishing a profile
629
+
630
+ The [profile starter kit](./examples/profile-starter-kit/) is a complete publishable npm package template. Copy the subtree, replace placeholders, swap in your Z-segments and fixtures, then `pnpm publish --access public`. See [`examples/profile-starter-kit/CUSTOMIZING.md`](./examples/profile-starter-kit/CUSTOMIZING.md) for the 5-step walkthrough — rename, swap base profile, define Z-segments, write fixtures, publish.
631
+
632
+ ### Built-in profiles
633
+
634
+ Five profiles ship in the box, reachable via the `profiles` namespace:
635
+
636
+ - `profiles.epic` — Epic Bridges Interconnect. Adds `MM/DD/YYYY HH:mm:ss` and `MM/DD/YYYY` date formats; declares `ZDP` (department context) and `ZRS` (result status) Z-segments.
637
+ - `profiles.cerner` — Cerner Millennium outbound. Handles Cerner-idiomatic date formats and common Z-segments from Millennium ADT feeds.
638
+ - `profiles.meditech` — MEDITECH EXPANSE/6.x. Covers MEDITECH-specific MRN picking and known Z-segment patterns.
639
+ - `profiles.athena` — athenahealth. Ambulatory-oriented, ISO-leaning date formats.
640
+ - `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.
641
+
642
+ Use a built-in directly (`parseHL7(raw, profiles.epic)`) or as a base for your own (`defineProfile({ extends: profiles.epic, ... })`).
643
+
644
+ ---
645
+
646
+ ## Real-World Tolerance
647
+
648
+ Production HL7 traffic routinely violates the published spec — trailing whitespace, MLLP framing, mixed-case segment names, unknown escape sequences, non-canonical timestamps. A parser that rejects those messages is useless on real integrations. Postel's Law applies: the parser is liberal, the emitter is conservative.
649
+
650
+ Every deviation the parser encounters is classified into one of four tiers:
651
+
652
+ | Tier | Behavior | When | Example codes |
653
+ | ---- | -------------- | ------------------------------ | ------------------------ |
654
+ | 0 | Silent | Spec-compliant input | — |
655
+ | 1 | Auto-handled | Trivial deviation, no warning | Trailing whitespace tidy |
656
+ | 2 | Warning | Recoverable deviation | `MLLP_FRAMING_STRIPPED` |
657
+ | 3 | Fatal (always) | Unrecoverable structural error | `NO_MSH_SEGMENT` |
658
+
659
+ Tier-2 warnings are plain data attached to `msg.warnings`. Every warning carries a stable string `code`, a human-readable `message`, and a `position` with 1-indexed `segmentIndex`/`fieldIndex`/etc. so you can programmatically react to specific deviations:
660
+
661
+ ```ts
662
+ import { parseHL7 } from "@cosyte/hl7";
663
+
664
+ const msg = parseHL7(raw);
665
+ for (const w of msg.warnings) {
666
+ console.log(`${w.code} at segment ${w.position.segmentIndex}: ${w.message}`);
667
+ }
668
+ ```
669
+
670
+ 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.
671
+
672
+ 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.
673
+
674
+ The 4 Tier-3 fatal codes — `NO_MSH_SEGMENT`, `MSH_TOO_SHORT`, `INVALID_ENCODING_CHARACTERS`, `EMPTY_INPUT` — always throw regardless of mode. They represent inputs the parser can't meaningfully recover from.
675
+
676
+ ---
677
+
678
+ ## Error Handling
679
+
680
+ The library throws exactly three error types, all exported from the package barrel.
681
+
682
+ ### `Hl7ParseError`
683
+
684
+ Thrown by `parseHL7` when the input hits one of the 4 Tier-3 fatal codes (see above). Carries positional context plus a short snippet of the offending input.
685
+
686
+ ```ts
687
+ import { parseHL7, Hl7ParseError, FATAL_CODES } from "@cosyte/hl7";
688
+
689
+ try {
690
+ parseHL7("");
691
+ } catch (err) {
692
+ if (err instanceof Hl7ParseError) {
693
+ console.log(err.code); // "EMPTY_INPUT"
694
+ console.log(err.position); // { segmentIndex: 0 }
695
+ console.log(err.snippet); // "" (first 40 chars of input, for logging)
696
+ }
697
+ }
698
+
699
+ // Narrow exhaustively with the FATAL_CODES registry:
700
+ if (err instanceof Hl7ParseError && err.code === FATAL_CODES.NO_MSH_SEGMENT) {
701
+ // ...
702
+ }
703
+ ```
704
+
705
+ `Hl7ParseError.snippet` may contain PHI when parsing real clinical messages. The library does NOT redact — redact at your call site if compliance demands it.
706
+
707
+ ### `Hl7ParseWarning`
708
+
709
+ Tier-2 deviations — plain data, not thrown. Accumulated on `msg.warnings` and delivered to any `onWarning` callback in parse order. See the iteration example in [Real-World Tolerance](#real-world-tolerance).
710
+
711
+ ```ts
712
+ import type { Hl7ParseWarning, WarningCode } from "@cosyte/hl7";
713
+
714
+ function label(w: Hl7ParseWarning): string {
715
+ const c: WarningCode = w.code;
716
+ switch (c) {
717
+ case "MLLP_FRAMING_STRIPPED":
718
+ return "framed input";
719
+ case "UNKNOWN_SEGMENT":
720
+ return `unknown: ${w.message}`;
721
+ default:
722
+ return c;
723
+ }
724
+ }
725
+ ```
726
+
727
+ Use the `WarningCode` union + `switch` for exhaustive handling — the type system catches missing cases if you enable `switch-exhaustiveness-check`.
728
+
729
+ ### `ProfileDefinitionError`
730
+
731
+ Thrown by `defineProfile()` when the options are structurally invalid. Covers the four failure modes locked by the profile system:
732
+
733
+ ```ts
734
+ import { defineProfile, ProfileDefinitionError } from "@cosyte/hl7";
735
+
736
+ try {
737
+ defineProfile({
738
+ name: "broken",
739
+ customSegments: {
740
+ AB: { fields: { foo: 1 } }, // bad: segment name must match /^Z[A-Z0-9]{2}$/ or similar
741
+ },
742
+ });
743
+ } catch (err) {
744
+ if (err instanceof ProfileDefinitionError) {
745
+ console.log(err.profileName); // "broken"
746
+ console.log(err.message); // actionable diagnostic
747
+ }
748
+ }
749
+ ```
750
+
751
+ The four cases that throw `ProfileDefinitionError`:
752
+
753
+ 1. **Missing or non-string `name`** — every profile must identify itself.
754
+ 2. **Malformed `customSegments`** — segment name must pass the Z-segment regex; each field entry must map to a positive 1-indexed integer; no duplicate field names across merged segments.
755
+ 3. **Unsupported `dateFormats` tokens** — tokens must be drawn from `SUPPORTED_DATE_TOKENS` (re-exported from the package barrel for introspection).
756
+ 4. **Unknown option keys** — the options bag is closed; typos throw with "did you mean" hints.
757
+
758
+ All three error types (and the `FATAL_CODES` / `WARNING_CODES` registries, and the `SUPPORTED_DATE_TOKENS` set) are top-level exports — no internal reaching required.
759
+
760
+ ---
761
+
762
+ ## Roadmap
763
+
764
+ Not in v1, but on the roadmap for v2:
765
+
766
+ - **Typed message overlays** (`msg.is("ADT^A01")` narrows to `AdtA01Message`) — message-type-aware getter narrowing.
767
+ - **Schema-aware structure validation** — opt-in structural validator that enforces segment ordering + cardinality against the HL7 spec.
768
+ - **Streaming parser for large batch files** — `createHL7Stream()` returning an iterable of messages, for multi-GB batch processing.
769
+ - **JSON Schema / Zod emission for `toJSON()` output** — autogenerated schemas from the internal typed model.
770
+ - **Type-safe custom-segment field names via conditional types** — `seg.get("departmentCode")` narrows to the profile's declared field alias, not `string | undefined`.
771
+
772
+ ### Out of scope (permanently)
773
+
774
+ - **MLLP framing / network transport** — a future `@cosyte/hl7-mllp` package will cover network IO; this library is parser-only.
775
+ - **HL7 v3 and CDA** — different spec family entirely; not in our plans.
776
+ - **FHIR conversion** — a future companion package could bridge, but the conversion is non-trivial and deserves its own repo.
777
+ - **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.
778
+
779
+ ---
780
+
781
+ ## Contributing
782
+
783
+ 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.
784
+
785
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for dev setup, how to file an issue, and how to submit a PR.
786
+
787
+ ---
788
+
789
+ ## License
790
+
791
+ MIT — see [LICENSE](./LICENSE).
792
+
793
+ ---
794
+
795
+ _Built by [Cosyte](https://cosyte.com)._