HL7v2

    _   _ _   ___        ____
   | | | | | |__ \__   _|___ \
   | |_| | |    ) \ \ / / __) |
   |  _  | |__ / / \ V / / __/
   |_| |_|____|_/   \_/ |_____|

   Pure Elixir HL7 v2.x Toolkit
   Schema-driven parsing, building, and MLLP transport.

Hex.pmDocsLicense

Pure Elixir HL7 v2.x toolkit — typed segment structs, programmatic message building, structural validation, and integrated MLLP transport.

What You Get

# Typed structs with named fields
{:ok, msg} = HL7v2.parse(text, mode: :typed)
pid = Enum.find(msg.segments, &is_struct(&1, HL7v2.Segment.PID))
pid.patient_name  #=> [%XPN{family_name: %FN{surname: "Smith"}, given_name: "John"}]

# Build messages programmatically
msg = HL7v2.Message.new("ADT", "A01", sending_application: "PHAOS")
      |> HL7v2.Message.add_segment(%HL7v2.Segment.PID{
           patient_name: [%XPN{family_name: %FN{surname: "Smith"}, given_name: "John"}]
         })

Installation

def deps do
  [{:hl7v2, "~> 3.0"}]
end

Upgrading from 2.x?send_message/3 now returns {:error, :protocol_desync} and terminates the client when stale bytes are detected in the MLLP buffer. See CHANGELOG for the full breaking change description.

Quick Start

Parse

# Raw mode — canonical round-trip, minimal overhead
{:ok, raw} = HL7v2.parse(text)
raw.type  #=> {"ADT", "A01"}

# Typed mode — segments become structs
{:ok, msg} = HL7v2.parse(text, mode: :typed)

# Access fields naturally
HL7v2.get(msg, "PID-5")   #=> %XPN{family_name: %FN{surname: "Smith"}, ...}
HL7v2.get(msg, "PID-3")   #=> %CX{id: "12345", identifier_type_code: "MR"}
HL7v2.get(msg, "PID-8")   #=> "M"
HL7v2.get(msg, "PID-3[2]") #=> second identifier (repetition)

Build

msg =
  HL7v2.Message.new("ADT", "A01",
    sending_application: "PHAOS",
    sending_facility: "HOSP"
  )
  |> HL7v2.Message.add_segment(%HL7v2.Segment.PID{
    set_id: 1,
    patient_identifier_list: [
      %HL7v2.Type.CX{id: "MRN001", identifier_type_code: "MR"}
    ],
    patient_name: [
      %HL7v2.Type.XPN{
        family_name: %HL7v2.Type.FN{surname: "Smith"},
        given_name: "John"
      }
    ],
    administrative_sex: "M"
  })

wire = HL7v2.encode(msg)
# => "MSH|^~\\&|PHAOS|HOSP|...\rPID|1||MRN001^^^^MR||Smith^John|||M\r"

Validate

{:ok, typed} = HL7v2.parse(text, mode: :typed)

case HL7v2.validate(typed) do
  :ok ->
    :good

  {:error, errors} ->
    # [%{level: :error, location: "PID", field: :patient_name,
    #    message: "Required field is missing"}]
    Enum.each(errors, &IO.inspect/1)
end

ACK/NAK

# Accept
{ack_msh, msa} = HL7v2.Ack.accept(original_msh)
wire = HL7v2.Ack.encode({ack_msh, msa})

# Reject with error details
{ack_msh, msa, err} = HL7v2.Ack.reject(original_msh,
  text: "Unknown patient",
  error_code: "204"
)

MLLP Transport

# Server
defmodule MyHandler do
  @behaviour HL7v2.MLLP.Handler

  @impl true
  def handle_message(message, _meta) do
    {:ok, typed} = HL7v2.parse(message, mode: :typed)
    msh = hd(typed.segments)
    {ack_msh, msa} = HL7v2.Ack.accept(msh)
    {:ok, HL7v2.Ack.encode({ack_msh, msa})}
  end
end

{:ok, _} = HL7v2.MLLP.Listener.start_link(port: 2575, handler: MyHandler)

# Client
{:ok, client} = HL7v2.MLLP.Client.start_link(host: "hl7.hospital.local", port: 2575)
{:ok, ack} = HL7v2.MLLP.Client.send_message(client, wire)

# TLS / mTLS
{:ok, _} = HL7v2.MLLP.Listener.start_link(
  port: 2576,
  handler: MyHandler,
  tls: HL7v2.MLLP.TLS.mutual_tls_options(certfile: "cert.pem", keyfile: "key.pem", cacertfile: "ca.pem")
)

Coverage

Schema Coverage

Every official HL7 v2.5.1 segment, data type, and message structure has a typed Elixir module. Run mix hl7v2.coverage --detail for per-segment field completeness.

 Segments    152 of 152 v2.5.1 segments + generic ZXX
 Types       89 official v2.5.1 data types + legacy TN
 Structures  186 of 186 official v2.5.1 abstract structures (222 total with aliases)

Validation Coverage

Validation is opt-in (HL7v2.validate/2) and layered:

 Structural  positional order/group/cardinality for all 186 official structures
 Fields      required-field checks, bounded repetition enforcement
 Tables      189 HL7 coded-value tables, 255 field bindings (opt-in: validate_tables: true)
 Conditional 24 segment-local inter-field rules (see Known Limitations)

Transport

 MLLP        Ranch 2.x listener, GenServer client, TLS/mTLS, telemetry

Conformance Corpus

Real-wire fixtures validated end-to-end (raw parse → typed parse → round-trip → strict validation with zero warnings):

 Fixtures    111 wire files covering 101 unique canonical structures
 Breadth     101 of 186 official v2.5.1 structures (54.3%)

The corpus is computed at compile time from the fixture directory and frozen into HL7v2.Conformance.Fixtures. The fixture files themselves (test/fixtures/conformance/*.hl7) ship inside the Hex package. A CI-level tarball test verifies that the built .tar contains exactly the same number of .hl7 files as the frozen compile-time list.

Freshness caveat.@external_resource only tracks files that existed at compile time — adding or removing fixtures from the source tree requires recompiling the module before the helper picks them up. The strict-clean test suite (test/hl7v2/conformance/round_trip_test.exs) enumerates fixtures at runtime via File.ls and includes a freshness guard that delegates to HL7v2.Conformance.Fixtures.check_freshness/1, which is itself covered by automated stale-case tests.

Fixture filenames, canonical structures, and family prefixes are exposed via:

HL7v2.Conformance.Fixtures.coverage()
HL7v2.Conformance.Fixtures.list_fixtures()
HL7v2.Conformance.Fixtures.unique_canonical_structures()
HL7v2.Conformance.Fixtures.families()

Canonical resolution uses the same alias fallback as HL7v2.Validation — an ACK^A01^ACK_A01 fixture correctly reports as covering the registered ACK structure, not the unregistered ACK_A01.

Scope

HL7 v2.5.1 baseline schema with version-aware validation for v2.3 through v2.8. The parser and encoder round-trip messages at any version in that range, and HL7v2.validate/2 extracts MSH-12 (version_id) to apply version-specific rules:

Known Limitations

Typed mode canonicalizes wire values. Trailing empty components are trimmed during encoding (Smith^John^^^^^ becomes Smith^John). Parse → encode is idempotent but not identity-preserving against the original wire form.

Three fields remain raw-typed. OBX-5 (observation value) is VARIES per the spec and dispatched at runtime via OBX-2 (41 value types supported). QPD-3 (user parameters) and RDT-1 (column value) are query-specific and cannot be statically typed. These are the three remaining standard gaps in the typed coverage model.

Conditional validation is mostly segment-local. The 24 conditional rules check HL7 inter-field dependencies. Scheduling segments (AIS, AIG, AIL, AIP, RGS) and PV2 transfer rules are trigger-aware: when the message trigger event is available (extracted from MSH-9), modification triggers (S03-S11) and transfer triggers (A02, A06, A07, etc.) produce definitive checks instead of heuristic warnings. Without trigger context (e.g., when calling conditional_errors/3 directly), the original heuristic fallback is preserved. Pass mode: :strict for error-level enforcement.

Run mix hl7v2.coverage for detailed per-segment field completeness.

Handling Unknown Segments

Real-world HL7 is messy. Messages arrive with vendor-specific Z-segments, obsolete segments from older versions, and segments your system doesn't care about. The library handles all of them without crashing or losing data:

{:ok, msg} = HL7v2.parse(text, mode: :typed)

# Known segments → typed structs with named fields
%HL7v2.Segment.PID{patient_name: [%XPN{...}], ...}

# Z-segments → ZXX struct preserving segment ID and all raw fields
%HL7v2.Segment.ZXX{segment_id: "ZPD", raw_fields: ["custom", "data"]}

# Unknown segments from other versions → raw tuples, lossless
{"XYZ", ["1", "DATA001", ...]}

All three forms encode back to valid HL7 wire format. The typed API (get/2, fetch/2, ~h sigil) works across all forms — typed segments return struct fields with component and repetition selection, raw tuples return whole fields by position (component/repetition selectors are not applied to raw tuples).

This means you can parse any HL7 message from any source, work with the segments you understand, and forward the rest unchanged. No schema registration required.

Documentation

Full API docs: hexdocs.pm/hl7v2

Getting started guide included.

Part of the Balneario Healthcare Toolkit

Three pure-Elixir libraries covering the core protocol surface of healthcare IT. Zero NIFs. Built for production.

Library Domain Standards
dicom Medical imaging data PS3.5 / 6 / 10 / 15 / 16 / 18 Hex · Docs · GitHub
dimse DICOM networking PS3.7 / 8 / 15 Hex · Docs · GitHub
hl7v2 Clinical messaging HL7 v2.5.1 Hex · Docs · GitHub

dicom parses and writes DICOM files. dimse moves them over the network via DIMSE-C/N services. hl7v2 handles the clinical messages (ADT, ORM, ORU) that trigger and contextualize imaging workflows.

Together they give Elixir the same healthcare protocol coverage that Java has with dcm4che + HAPI, or C++ with DCMTK — on the BEAM.

License

MIT — see LICENSE.