Skip to content

Repository files navigation

Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark

@cosyte/transform

HL7 v2 → FHIR R4 transformation for Node.js and TypeScript: IG-grounded, fail-safe, value-free diagnostics; never a confident wrong FHIR value.

@cosyte/transform is the healthcare transformation layer of the cosyte suite. Unlike the parsers, it is a consumer: it takes already-parsed @cosyte/hl7 composites and produces validated @cosyte/fhir model nodes, grounded on the official HL7 Version 2 to FHIR Implementation Guide (hl7.fhir.uv.v2mappings).

"Validated" there means a small internal required-element check before a resource ships, which is a floor this library wrote for itself and not a statement about FHIR conformance. How far the output actually conforms is measured separately, against pinned FHIR R4 4.0.1 definitions and the pinned hl7.fhir.us.core version 9.0.0 profiles, over the guide's own published test messages, and the outcome is published in documentation/conformance/report.md. Read it before relying on the word "validated": today it says none of the seven published test messages produces a Bundle that is clean against R4 plus those profiles, and it lists every finding. The summary is under Conformance: measured, not asserted.

Status: pre-alpha (0.0.x), published to npm. This release ships the six safety-critical datatype converters and the value-free diagnostic channel, the message-level assembly, HL7 v2 ADT → FHIR Patient + Encounter, the ORU^R01 → FHIR DiagnosticReport + Observation results graph, the order-entry graph (ORM_O01 / OML_O21 → ServiceRequest and RXO → MedicationRequest), the thin IG singles (VXU_V04 → Immunization, SIU_S12 → Appointment, MDM_T02 → DocumentReference), all via toFhir(msg), and terminology value translation of coded fields: route/site, appointment type, order priority, and substitution are now value-translated through their IG mappedVia ConceptMaps via toFhirCodeableConceptVia, fail-safe on any code the IG leaves unmapped. The v2→FHIR direction is feature-complete for the IG-covered message set. It also ships a narrow reverse path, FHIR → v2: toV2Patient(patient, trigger) and toV2Observation(observation, trigger) emit a complete v2 message carrying a PID or an OBX. Deeper terminology, profiles, and any wider FHIR → v2 conversion are not implemented.

Install

npm install @cosyte/transform @cosyte/hl7 @cosyte/fhir

@cosyte/hl7 and @cosyte/fhir are peer dependencies: the transform maps between the models they own. Its own third-party runtime dependencies are zero.

That command does not work yet. This package is published, but @cosyte/fhir is not on the registry, so npm fails with ERESOLVE and refuses to resolve that peer. Until it publishes, consume this package from source or a workspace link.

Convert a datatype

import { toFhirHumanName } from "@cosyte/transform";

const { value, issues } = toFhirHumanName({
  familyName: "Public",
  givenName: "Jane",
  nameTypeCode: "L", // HL7 Table 0200 "Legal name" → FHIR name-use "official"
});
// value: a FHIR HumanName node; issues: [] (clean, fully mapped)

Each converter returns { value, issues }: the FHIR datatype node it could faithfully produce, plus the value-free diagnostics it raised.

The fail-safe rule

Every conversion is grounded on the IG and refuses to guess. On any of these ambiguities:

  • a v2 timestamp with a time-of-day but no timezone (FHIR forbids time without a zone),
  • an assigning authority that can't be resolved to a system URI (never synthesized from a bare namespace, which would merge two patients),
  • a code with an unrecognized or absent coding system,
  • a unit that isn't valid UCUM (magnitudes are never converted),

the converter produces what it can (often reduced in precision) and raises a typed, value-free TransformIssue: a stable code, the v2 location, and the FHIR path, never a value. Render a list of issues as a FHIR OperationOutcome with toOperationOutcome(issues).

The six datatype converters

Converter v2 → FHIR
toFhirDateTime DTM/TS → dateTime
toFhirIdentifier CX → Identifier
toFhirCodeableConcept CWE/CE → CodeableConcept
toFhirHumanName XPN → HumanName
toFhirAddress XAD → Address
toFhirQuantity NM + units → Quantity

Assemble a message

toFhir(msg) takes a parsed @cosyte/hl7 ADT message and returns a FHIR R4 message Bundle: a MessageHeader, then the Patient and Encounter (and RelatedPerson, one AllergyIntolerance per AL1, one Condition per DG1, one Procedure per PR1 and one Coverage per IN1) it describes, plus the value-free issues, each map grounded firsthand on the IG's segment/table ConceptMaps.

import { parseHL7 } from "@cosyte/hl7";
import { toFhir, createNamingSystem } from "@cosyte/transform";

const { bundle, issues } = toFhir(parseHL7(raw), {
  namingSystem: createNamingSystem({ authorities: { HOSP: "urn:oid:1.2.840.114350" } }),
});
// bundle.type === "message"; every reference resolves to a urn:uuid: fullUrl inside the bundle.
Segment FHIR resource key maps
MSH MessageHeader MSH-9 → eventCoding; MSH-7/10 → Bundle.timestamp/.identifier
PID Patient PID-3/5/7/8/11 → identifier/name/birthDate/gender/address
PV1 Encounter PV1-2 → class/status (HL70004); PV1-19/44/45 → identifier/period
NK1 RelatedPerson NK1-2/3/4 → name/relationship/address
AL1 AllergyIntolerance AL1-2 → category + type (two maps); AL1-3/4/5 → code/criticality/reaction
DG1 Condition DG1-3/4 → code/code.text; DG1-5/19 → onsetDateTime/recordedDate; DG1-21 DverificationStatus
PR1 Procedure PR1-3/4 → code/code.text; PR1-5 + PR1-7 → performedDateTime or performedPeriod; PR1-6/15 → category/reasonCode
IN1 Coverage IN1-2/15 → identifier/type; IN1-12/13 → period; IN1-4.1 → payor.display; IN1-10/49 → the subscriber-id extension

An allergy is a field a downstream system may act on before prescribing, so the AL1 rows are read strictly. clinicalStatus is the active the IG assigns (its constraint ait-1 needs one, and no AL1 component can say otherwise). AL1-2 resolves category and type against two separate IG maps over Table 0127, independently, so a MA gets the type allergy and no category at all; whatever either map translates, the original v2 code is carried beside it in the IG's alternate-codes extension, so nothing the sender wrote is lost. criticality is the only severity target (AllergyIntolerance.reaction.severity is a local variation the guide conditions on something no message states), so an HL70128 code the map has no target for leaves it absent and flagged. An AL1 whose AL1-3 names no substance, or that arrives with no Patient to anchor, is withheld and declared rather than emitted: an allergy to nothing, or one pointing at nobody, is worse than a reported gap. AL1-6 is legacy input only, read as onsetDateTime for a message earlier than 2.7 (the version that withdrew the field) and dropped with a diagnostic otherwise.

The diagnosis, procedure and coverage a message carries are read the same way. Each is wired to the bundle Patient, and each Condition is referenced back from Encounter.diagnosis when the message also described a visit; with no Patient to anchor them all three are withheld and declared, never emitted pointing at nobody. Three refusals are worth knowing before you consume them. Coverage.status is never asserted: the IN1 map publishes no row for it and R4's binding has no neutral member, so the element ships value-absent with a data-absent-reason of unknown and a diagnostic saying so. Read it as unknown, not as active coverage. Coverage.payor names the insurer without resolving it: IN1-4.1 becomes a reference display and no literal reference, because no Organization is built, and an IN1 that names no insurance company is withheld entirely, since payor is required and nothing else grounds it. Procedure.status is the unknown the guide's own row directs where the message context determines none; a completed this library was never told is never selected. A DG1-21 of D sets verificationStatus to entered-in-error, the one value the map assigns, and every other Table 0206 action code leaves the element absent and flagged. Every row of the three maps whose target needs a Practitioner, Location or Organization, or resolves by identifier rather than by bundle position, raises a diagnostic naming the field and the path it did not build, so what was deferred is in the issues list rather than missing without comment.

The fail-safe rule holds at the message level: an unmapped patient class, a naked timestamp, or an unresolvable authority becomes a typed issue, never a fabricated value. A trigger the IG has no message map for is assembled from the segment maps and flagged, never invented; every emitted resource is checked against a small internal required-element schema before it ships, which is a floor this library wrote for itself and not a statement about FHIR conformance. What conformance the output actually reaches is measured separately, and published.

Conformance: measured, not asserted

The guide publishes seven v2 test messages. This repository carries them, transforms every one of them, validates every resource of the resulting Bundle against the pinned FHIR R4 4.0.1 definitions and the pinned hl7.fhir.us.core version 9.0.0 profiles, and publishes the outcome in documentation/conformance/report.md, with the machine-readable form beside it in documentation/conformance/result.json.

Today that result is: none of the seven messages produces a Bundle with zero error-severity results against R4 plus those profiles. Against the base R4 4.0.1 definitions alone, without any profile, six of the seven are clean; the exception is SIU_S12, whose Appointment is booked with no start, which R4's own app-3 invariant forbids. The profile findings are mostly one shape: US Core requires elements the guide's segment maps publish no row for (Encounter.type, Coverage.relationship, Observation.category, DocumentReference.category), and this library leaves an ungrounded element absent rather than guessing at it. Every finding is listed in the report, per message, per resource, with the element path and the profile version it came from.

Two things that result does not say. It says nothing about mapping correctness: a clean Bundle is well formed and profile-conformant, not proof that the right v2 field reached the right FHIR element. And it is not a full implementation-guide validator run: the report states which classes of check were performed and which were not, external terminology resolution among the latter. Both packages are carried in the repository and verified by digest on every run, so the number is reproducible against a pin rather than against whatever was current; pnpm run conformance regenerates it, and the test suite fails when the published result and a live run disagree.

And silence is not completeness. Every segment occurrence that contributed nothing to a resource in the returned bundle raises one value-free issue naming it, so you can read the issues list instead of diffing the message against the bundle: TRANSFORM_SEGMENT_NOT_EMITTED when the IG publishes a segment map for that name (a gap here), TRANSFORM_SEGMENT_NO_IG_MAP when it publishes none, or when the name could not be classified at all (a gap in the standard, or a damaged line). The location is DG1[2] for a name that passes the v2 segment-identifier shape, 1-based among that name's occurrences, and [#4] for one that does not, with no part of the name reproduced. Being read and refused is not reaching: a counted RXE, an orphan OBX and a resource the emit gate withheld are all reported. A flagged segment is still not transformed: you learn what is missing, not what it said.

The same toFhir(msg) handles the other message families: ORU^R01DiagnosticReport (OBR) + Observation (OBX) + Specimen (SPM, referenced from the report that scopes it, and withheld with it rather than left orphaned), with each result's OBSERVATION-group NTE as Observation.note and OBX-2 selecting the guide's own value[x] (a DR period, an NR range, a TM time, an NA sampled-data waveform, and a Base64 ED payload carried into the guide's named valueAttachment extension byte-for-byte, undecoded), and the order-entry graph, ORM_O01 / OML_O21 ORC/OBR → ServiceRequest and RXO (+ RXR route) → MedicationRequest, with ServiceRequest.status grounded on the HL70119 → request-status ConceptMap and withheld when it cannot be grounded, and a MedicationRequest whose IG-ungrounded status is the honest unknown rather than a guess. RXE has no STU1 IG map and is flagged, never assembled.

An order's TQ1 becomes the schedule that order carries: dosageInstruction.timing on the MedicationRequest, occurrenceTiming on the ServiceRequest (and a group that would also yield an occurrenceDateTime from OBR-6 emits the timing alone and flags the dropped one, because occurrence[x] is a choice). TQ1-3 grounds Timing.code from the HL70335 repeat-pattern rows, repeat.period, repeat.periodUnit and repeat.when from the HL70528 rows the guide gives a v3-TimingEvent target; TQ1-7 / TQ1-8 give repeat.boundsPeriod; TQ1-10 and TQ1-11 are carried verbatim to the two different targets the guide names, dosageInstruction.additionalInstruction.text and the resource's own text narrative. Both are TX, a v2 primitive with no component structure, so they are read whole: a raw ^, & or ~ inside a free-text instruction is content, and a taper written 2 tabs^then 1 tab arrives with its second half intact rather than truncated at the first delimiter, right down to a delimiter the instruction ends on, or a row that is nothing but delimiters. Only a row that carries a value is written, though: an HL7 explicit null ("") says the field carries none, so it reaches no element and puts no marker in one, and no diagnostic either, because nothing was dropped. A row that did carry content the projection resolves away entirely (display markup alone), or a TQ1-11 of nothing but whitespace, which R4's txt-2 forbids in a narrative, likewise writes nothing but is flagged, so content that arrived is never mistaken for a field that was never sent. A schedule is fully grounded or absent and flagged: a repeat component the guide gives no target for (at any position, including one past the eleven the datatype defines), a code outside its published table or sent under a coding system that is not that table, a value that would need a unit rescale or an invented date, a field that narrows the schedule (TQ1-4, TQ1-5, TQ1-6, TQ1-12, TQ1-13, TQ1-14), an unusable or inverted bound, a period quantity arriving without its units or written with a minus sign (R4's tim-2 and tim-5 reject both), or a second TQ1 on one order or a second repeat pattern in one TQ1-3 (a repetition that carries no value is not one: Q4H~ sends one schedule, and it is read wherever in the field it sat), each withholds the whole Timing and raises a value-free diagnostic naming the cause. A half-built timing would read to the receiving system as a complete dosing instruction, which is the one outcome this library will not produce.

The thin single-trigger families complete the IG-covered message set: VXU_V04 RXA (+ RXR route, ORC) → Immunization (status via the IG's three conditioned rows: a delete action → entered-in-error, an unvalued RXA-20 → completed, else the HL70322 → event-status ConceptMap, with a valued-but-unmapped code withheld), SIU_S12 SCH/AIS/PID → Appointment (status via the HL70278 → appointmentstatus ConceptMap, the Patient wired as the required participant, its IG-unsourced required status a data-absent-reason primitive), and MDM_T02 TXA/OBX → DocumentReference (status grounded only for TXA-19 AVcurrent, the document body base64-encoded verbatim, carried and never interpreted). Timezone-naked instants are dropped and flagged, never assigned a fabricated UTC offset.

toFhirCodeableConceptVia(cwe, map) applies a license-clean IG value ConceptMap (transcribed and verified firsthand against the raw published IG JSON): RXR route/site (HL70162/HL70550), SCH-8 appointment type (HL70277), RXO-9 substitution (HL70161), OBR-5 priority (HL70485), and the five AL1 allergy maps (HL70127 to category and to type, HL70128 to criticality, and the two original-code identity maps behind the alternate-codes extension), translating the source table code to its FHIR target coding, additively (the raw coding is preserved alongside the derived one). It is fail-safe by refusal: a code the IG map leaves in its (unmapped) group is flagged, never coerced to a neighbour. Fields whose IG target is SNOMED CT (RXR-4 method, SCH-7 reason) stay structural, because SNOMED is not bundled (BYO ConceptMap), and fields the IG ships no value map for (TXA-2 document type, RXA-5 vaccine code) are carried as-is, never invented.

Emit v2 back out, narrowly

Two entry points go the other way, FHIR → v2. Each takes the FHIR resource plus the v2 trigger you want the message to carry, and returns the same { value, issues } envelope, where value is a complete @cosyte/hl7 message:

Function in out
toV2Patient(patient, trigger) FHIR Patient a v2 ADT^<trigger> message with a PID
toV2Observation(observation, trigger) FHIR Observation a v2 ORU^<trigger> message with an OBX
import { parseResource } from "@cosyte/fhir";
import { toV2Patient } from "@cosyte/transform";

const { resource } = parseResource(patientJson);
const { value, issues } = toV2Patient(resource, "A28", {
  assigningAuthorities: { "urn:oid:1.2.840.114350": "HOSP" },
  envelope: { sendingApp: "EHR", sendingFacility: "MAIN" },
});
// value.toString() -> "MSH|^~\\&|EHR|MAIN|...|ADT^A28|...\rPID|||MRN1||Public^Jane\r"

The trigger is required and is never inferred. No FHIR resource carries an HL7 v2 message trigger, so there is nothing to derive one from: supply it, or the call returns no message and one TRANSFORM_MISSING_TRIGGER diagnostic, without building anything.

This direction is lossy by design, and it is not a round-trip. The published mapping guide runs v2 → FHIR, and several of its rows are many-to-one, so their inverse is ambiguous and is refused: gender other, name use official and temp, address use work, every Address.type, and Observation.status entered-in-error each leave their v2 field absent with a TRANSFORM_CODE_NOT_INVERTIBLE diagnostic rather than picking one of the v2 codes that could have produced them. An element with no v2 field in this narrow map is flagged (TRANSFORM_NO_V2_TARGET), a value v2 cannot carry unchanged is flagged and left out (TRANSFORM_VALUE_NOT_REPRESENTABLE), and a coding system with no v2 mnemonic is flagged rather than written under a borrowed table (TRANSFORM_CODE_SYSTEM_NOT_V2). Nothing here reconstructs the message a resource came from, and nothing claims to.

What v2 requires but your resource does not carry is left absent, and said out loud. A PID needs PID-3 (Patient Identifier List) and PID-5 (Patient Name); an OBX needs OBX-11 (Observation Result Status). A resource that gives no source for one of them still gets a message with that field absent, never a placeholder invented to satisfy v2 structure, and one TRANSFORM_V2_REQUIRED_FIELD_ABSENT diagnostic per field, carrying the v2 location and the FHIR path it would have come from. A resource that grounds no field of the target segment at all returns no message and one TRANSFORM_NO_V2_MESSAGE_EMITTED, so an empty-handed conversion is never mistaken for a successful one. Both are error severity: an emitted message missing a field v2 requires is not conformant, and this is where you find that out rather than at the receiver.

License

MIT © Cosyte

About

HL7 v2 -> FHIR (and beyond) healthcare data transformation, IG-grounded. Consumes @cosyte/hl7 + @cosyte/fhir. Pre-launch.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages