# 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 20 typed structs (MSH EVN PID PV1 PV2 NK1 OBR OBX ORC
MSA ERR NTE AL1 DG1 IN1 SCH AIS GT1 FT1 ZXX)
Types 36 composite + 8 primitive (44 total) — 97% of segment fields typed
Messages ADT (A01-A04, A08) ORM^O01 ORU^R01 SIU^S12 ACK
with structure validation rules
Transport MLLP framing, Ranch 2.x listener, GenServer client,
TLS/mTLS, telemetry instrumentation
Tests 1,620 (241 doctests + 30 properties + 1,349 tests)
Coverage 95%+
Speed 0.8s 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).
**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:** 20 of ~120 standard segments (plus generic ZXX) typed. Extra fields beyond
declared definitions are preserved in `extra_fields` for lossless round-trip. Common message
families: ADT_A01 ~11/23, ORM_O01 ~12/23, ORU_R01 ~10/18, ACK 3/4, SIU_S12 ~9/15.
44 type modules (36 composite + 8 primitive).
## 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).