# HL7v2
```
_ _ _ ___ ____
| | | | | |__ \__ _|___ \
| |_| | | ) \ \ / / __) |
| _ | |__ / / \ V / / __/
|_| |_|____|_/ \_/ |_____|
Pure Elixir HL7 v2.x Toolkit
Schema-driven parsing, building, and MLLP transport.
```
[](https://hex.pm/packages/hl7v2)
[](https://hexdocs.pm/hl7v2)
[](https://github.com/Balneario-de-Cofrentes/hl7v2/blob/main/LICENSE)
The HL7v2 library that treats clinical messages as first-class data structures,
not bags of strings.
## The Difference
Other Elixir HL7v2 libraries give you string maps. We give you structs:
```elixir
# elixir_hl7 — strings all the way down
HL7.get(msg, ~p"PID-5") # => "Smith^John"
# hl7v2 — typed structs with named fields
pid = Enum.find(typed.segments, &is_struct(&1, HL7v2.Segment.PID))
pid.patient_name # => [%XPN{family_name: %FN{surname: "Smith"}, given_name: "John"}]
# Build messages programmatically — no other Elixir library does this:
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"}]
})
```
## Why HL7v2
| | elixir_hl7 | **hl7v2** |
|---|---|---|
| Data model | Sparse maps, integer keys | **Typed structs, named fields** |
| Building messages | Not supported | **`Message.new` + `add_segment`** |
| Validation | None (by design) | **Opt-in required-field + segment-presence checks** |
| Transport | Separate package (mllp) | **Integrated MLLP** |
| Ranch | 1.8 | **2.x** |
| Parse + type | Two steps | **`mode: :typed` in one call** |
| ACK/NAK | Manual | **`Ack.accept/error/reject`** |
| TLS | Separate config | **Built-in mTLS helpers** |
| Path access | `~p"PID-5"` sigil | **`get/2` + `fetch/2` with error tuples** |
| Telemetry | No | **`:telemetry` spans on all ops** |
## Installation
```elixir
def deps do
[{:hl7v2, "~> 0.1"}]
end
```
## Quick Start
### Parse
```elixir
# Raw mode — canonical round-trip, zero allocation 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
```elixir
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
```elixir
{: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
```elixir
# 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
```elixir
# 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: [certfile: "cert.pem", keyfile: "key.pem", cacertfile: "ca.pem", verify: :verify_peer]
)
```
## Coverage
```
Segments 22 typed structs (MSH EVN PID PV1 PV2 NK1 OBR OBX ORC
MSA ERR NTE AL1 DG1 IN1 SCH AIS RGS MRG GT1 FT1 ZXX)
22 of ~151 standard segments + generic Z-segment
Types 36 composite + 8 primitive (44 of ~89 standard types)
Messages ADT (A01-A04, A08, A12) ORM^O01 ORU^R01 SIU^S12 ACK
with required-segment presence validation
Transport MLLP framing, Ranch 2.x listener, GenServer client,
TLS/mTLS, telemetry instrumentation
Tests 1,630+ (241 doctests + 30 properties + 1,355+ tests)
Coverage ~95%
Speed <1s full suite
```
## Scope and Limitations
This library targets **HL7 v2.5.1** with permissive parsing of adjacent versions.
**What it does well:** delimiter parsing, typed segment/composite structs, canonical
round-trip encoding, programmatic message building, MLLP transport with TLS, and
basic validation (required fields, repetition limits, required-segment presence).
Raw mode is lossless for all valid HL7v2 messages. Typed mode covers a focused
ADT/ORM/ORU/SIU/ACK subset with extra_fields preservation for unlisted fields.
**What it does not do:**
- Segment ordering or group/cardinality validation (segments out of order pass validation)
- HL7 table value-set validation (any string accepted for coded fields)
- Conditional field logic (fields marked `:c` are not evaluated)
- Full message profile conformance (truncation character is supported; MSH-22+ is not)
- Text type semantics (ST, TX, FT are lossless pass-through — no delimiter rejection,
no whitespace normalization)
**Coverage:** 22 of ~151 standard segments (plus generic ZXX) typed. 44 of ~89 standard
data types. 20 of ~199 message structures with presence validation definitions. Extra
fields beyond declared definitions are preserved in `extra_fields` for lossless round-trip.
OBX exposes 19 of 25 fields; OBR exposes 49 of 50 — unlisted fields survive as extra_fields.
Some typed segment fields fall back to `:raw` where their HL7 data types (TQ, SPS, ELD) are
not yet implemented — these fields are preserved but not parsed into typed structs.
## 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:
```elixir
{: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 standard segments → raw tuples, lossless
{"PR1", ["1", "I10", "99213", ...]}
```
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, raw tuples
return fields by position.
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](https://hexdocs.pm/hl7v2)
Getting started guide included.
## Part of the Balneario Healthcare Toolkit
```
dicom DICOM P10 parse/write/de-id hex.pm/packages/dicom
dimse DICOM networking (C-STORE/FIND) hex.pm/packages/dimse
hl7v2 HL7 v2.x parse/build/MLLP hex.pm/packages/hl7v2
```
Three pure-Elixir libraries. Zero NIFs. One team. Built for production
medical imaging and clinical messaging systems.
## License
MIT — see [LICENSE](LICENSE).