# fhir_ex
An Elixir library for working with [FHIR R5](https://www.hl7.org/fhir/R5/) resources. Provides typed structs, JSON serialization, and field-level validation for the resources and data types used in **lab exam ordering** and **patient admissions** workflows.
## Features
- Typed Elixir structs for all FHIR R5 data types and resources
- JSON encoding (`struct → JSON`) and decoding (`JSON → struct`) via [Jason](https://github.com/michalmuskala/jason)
- Nil fields omitted from JSON output; `resourceType` injected automatically
- Polymorphic `[x]` fields (e.g. `value[x]`, `occurrence[x]`) represented as tagged tuples
- Validation returning `{:ok, struct}` or `{:validation_error, [errors]}` with JSON-path error locations
- Recursive nested validation — errors from deep inside a struct carry their full path
## Installation
Add `fhir_ex` to your dependencies:
```elixir
def deps do
[
{:fhir_ex, "~> 0.1.0"}
]
end
```
## Quick start
### Lab order (ServiceRequest)
```elixir
alias FhirEx.Resources.ServiceRequest
alias FhirEx.Types.{CodeableConcept, Coding, Identifier, Reference, Annotation}
order = %ServiceRequest{
id: "order-001",
identifier: [
%Identifier{system: "http://hospital.org/orders", value: "ORD-2024-0042"}
],
status: "active",
intent: "order",
priority: "routine",
code: %CodeableConcept{
coding: [%Coding{system: "http://loinc.org", code: "58410-2", display: "CBC panel"}],
text: "Complete Blood Count"
},
subject: %Reference{reference: "Patient/patient-001"},
encounter: %Reference{reference: "Encounter/enc-001"},
requester: %Reference{reference: "Practitioner/pract-001"},
authored_on: "2024-01-15T08:00:00Z",
occurrence: {:date_time, "2024-01-15T10:00:00Z"},
note: [%Annotation{text: "Patient must fast for 8 hours before blood draw."}]
}
json = FhirEx.JSON.encode!(order)
```
<details>
<summary>JSON output</summary>
```json
{
"resourceType": "ServiceRequest",
"id": "order-001",
"status": "active",
"intent": "order",
"priority": "routine",
"authoredOn": "2024-01-15T08:00:00Z",
"occurrenceDateTime": "2024-01-15T10:00:00Z",
"code": {
"coding": [{"system": "http://loinc.org", "code": "58410-2", "display": "CBC panel"}],
"text": "Complete Blood Count"
},
"subject": {"reference": "Patient/patient-001"},
"encounter": {"reference": "Encounter/enc-001"},
"requester": {"reference": "Practitioner/pract-001"},
"identifier": [{"system": "http://hospital.org/orders", "value": "ORD-2024-0042"}],
"note": [{"text": "Patient must fast for 8 hours before blood draw."}]
}
```
</details>
### Lab result (Observation)
```elixir
alias FhirEx.Resources.Observation
alias FhirEx.Types.{CodeableConcept, Coding, Reference, Quantity}
observation = %Observation{
id: "obs-hemoglobin",
status: "final",
category: [
%CodeableConcept{
coding: [%Coding{
system: "http://terminology.hl7.org/CodeSystem/observation-category",
code: "laboratory"
}]
}
],
code: %CodeableConcept{
coding: [%Coding{system: "http://loinc.org", code: "718-7", display: "Hemoglobin"}],
text: "Hemoglobin"
},
subject: %Reference{reference: "Patient/patient-001"},
encounter: %Reference{reference: "Encounter/enc-001"},
effective: {:date_time, "2024-01-15T09:30:00Z"},
issued: "2024-01-15T10:00:00Z",
value: {:quantity, %Quantity{value: 14.5, unit: "g/dL", system: "http://unitsofmeasure.org", code: "g/dL"}},
reference_range: [
%{
low: %Quantity{value: 12.0, unit: "g/dL", system: "http://unitsofmeasure.org", code: "g/dL"},
high: %Quantity{value: 17.5, unit: "g/dL", system: "http://unitsofmeasure.org", code: "g/dL"},
text: "12.0–17.5 g/dL"
}
]
}
```
### Patient admission (Encounter)
```elixir
alias FhirEx.Resources.Encounter
alias FhirEx.Types.{CodeableConcept, Coding, Reference, Period}
encounter = %Encounter{
id: "enc-001",
status: "in-progress",
class: [
%CodeableConcept{
coding: [%Coding{
system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
code: "IMP",
display: "inpatient encounter"
}]
}
],
subject: %Reference{reference: "Patient/patient-001"},
service_provider: %Reference{reference: "Organization/org-001"},
actual_period: %Period{start: "2024-01-15T08:00:00Z"},
admission: %{
admit_source: %CodeableConcept{
coding: [%Coding{
system: "http://terminology.hl7.org/CodeSystem/admit-source",
code: "emd",
display: "From accident/emergency department"
}]
}
}
}
```
## JSON
### Encoding
```elixir
json = FhirEx.JSON.encode!(resource)
```
- `resourceType` is injected automatically for resources
- `nil` fields are stripped from the output
- Struct field names are converted to FHIR camelCase keys (`birth_date` → `"birthDate"`)
### Decoding
```elixir
resource = FhirEx.JSON.decode!(json_string, FhirEx.Resources.Patient)
```
Pass the target module as the second argument. Nested structs are reconstructed automatically.
### Round-trip
```elixir
patient
|> FhirEx.JSON.encode!()
|> FhirEx.JSON.decode!(Patient)
```
## Polymorphic fields
FHIR uses `[x]` to indicate a field that can hold one of several types (e.g. `value[x]`, `occurrence[x]`). In this library they are represented as **tagged tuples** in Elixir and serialised to the correct FHIR JSON key.
| Elixir (internal) | FHIR JSON key |
|---|---|
| `{:date_time, "2024-01-15T10:00:00Z"}` | `"occurrenceDateTime"` |
| `{:period, %Period{...}}` | `"occurrencePeriod"` |
| `{:quantity, %Quantity{...}}` | `"valueQuantity"` |
| `{:codeable_concept, %CodeableConcept{...}}` | `"valueCodeableConcept"` |
| `{:string, "Positive"}` | `"valueString"` |
| `{:boolean, true}` | `"valueBoolean"` |
| `{:range, %Range{...}}` | `"valueRange"` |
Resources that use polymorphic fields:
- `Observation` — `value[x]`, `effective[x]`
- `ServiceRequest` — `occurrence[x]`, `quantity[x]`, `asNeeded[x]`
- `Specimen` — `collected[x]`, `fastingStatus[x]`
- `DiagnosticReport` — `effective[x]`
## Validation
```elixir
alias FhirEx.Validation
alias FhirEx.Validation.Error
case Validation.validate(resource) do
{:ok, resource} ->
# proceed
{:validation_error, errors} ->
IO.puts(Error.format_all(errors))
end
```
`validate!/1` raises `ArgumentError` instead of returning the tuple:
```elixir
Validation.validate!(resource)
```
### Error structure
Each error carries a JSON-pointer style `path` list and a human-readable `message`:
```elixir
%Error{path: ["name", "1", "use"], message: "must be one of: usual | official | ..."}
```
Format helpers:
```elixir
Error.format(error) #=> "name.1.use: must be one of: usual | official | ..."
Error.format_all(errors) #=> newline-separated string of all errors
```
Nested struct errors are validated recursively and their paths are prefixed with the parent field and list index, so you always know exactly where the problem is.
### What is validated
| Type / Resource | Rules |
|---|---|
| `Coding` | `code` has no whitespace; `system` is a non-empty URI |
| `CodeableConcept` | at least one of `coding` or `text` present; nested codings valid |
| `Identifier` | `use` is a valid code; `system` is a non-empty URI |
| `HumanName` | `use` is a valid code; at least one name part present |
| `Address` | `use` and `type` are valid codes |
| `ContactPoint` | `system`/`use` are valid codes; `value` required when `system` is set |
| `Period` | `start`/`end` are valid FHIR dateTimes; `start` ≤ `end` |
| `Quantity` | `comparator` is a valid code; `system` required when `code` is set |
| `Range` | both quantities valid; `low.system` matches `high.system` |
| `Reference` | at least one of `reference`/`identifier`/`display`; reference string format |
| `Annotation` | `text` required; `authorReference` and `authorString` are mutually exclusive |
| `Extension` | `url` required; at most one `value[x]`; `value[x]` and nested extensions mutually exclusive |
| `Meta` | `versionId` matches FHIR id format; `lastUpdated` is a valid instant |
| `Narrative` | `status`/`div` required; `div` must be an XHTML `<div>` element |
| `Patient` | `gender`, `birthDate` format; `deceased*` and `multipleBirth*` mutual exclusivity |
| `Encounter` | `status` required and valid |
| `ServiceRequest` | `status`, `intent`, `subject` required; `priority` valid if set |
| `Observation` | `status`, `code` required; `value[x]` type validated; component `code` required |
| `Specimen` | `status` valid if set; `receivedTime` is a valid instant |
| `DiagnosticReport` | `status`, `code` required |
## Resources
| Module | FHIR resource | Primary use |
|---|---|---|
| `FhirEx.Resources.Patient` | [Patient](https://www.hl7.org/fhir/R5/patient.html) | Demographics and identifiers |
| `FhirEx.Resources.Practitioner` | [Practitioner](https://www.hl7.org/fhir/R5/practitioner.html) | Ordering clinician, result interpreter |
| `FhirEx.Resources.Organization` | [Organization](https://www.hl7.org/fhir/R5/organization.html) | Hospital, laboratory, clinic |
| `FhirEx.Resources.Encounter` | [Encounter](https://www.hl7.org/fhir/R5/encounter.html) | Admissions and visits |
| `FhirEx.Resources.ServiceRequest` | [ServiceRequest](https://www.hl7.org/fhir/R5/servicerequest.html) | Lab exam orders |
| `FhirEx.Resources.Observation` | [Observation](https://www.hl7.org/fhir/R5/observation.html) | Lab results and measurements |
| `FhirEx.Resources.Specimen` | [Specimen](https://www.hl7.org/fhir/R5/specimen.html) | Biological samples |
| `FhirEx.Resources.DiagnosticReport` | [DiagnosticReport](https://www.hl7.org/fhir/R5/diagnosticreport.html) | Report bundling observations |
## Data types
| Module | FHIR type | Notes |
|---|---|---|
| `FhirEx.Types.Primitives` | — | `@type` aliases for all 18 FHIR R5 primitives |
| `FhirEx.Types.Coding` | [Coding](https://www.hl7.org/fhir/R5/datatypes.html#Coding) | system + code + display |
| `FhirEx.Types.CodeableConcept` | [CodeableConcept](https://www.hl7.org/fhir/R5/datatypes.html#CodeableConcept) | `[Coding]` + text |
| `FhirEx.Types.Identifier` | [Identifier](https://www.hl7.org/fhir/R5/datatypes.html#Identifier) | MRN, NPI, accession number |
| `FhirEx.Types.Reference` | [Reference](https://www.hl7.org/fhir/R5/references.html) | Relative, absolute, logical, or contained |
| `FhirEx.Types.HumanName` | [HumanName](https://www.hl7.org/fhir/R5/datatypes.html#HumanName) | |
| `FhirEx.Types.Address` | [Address](https://www.hl7.org/fhir/R5/datatypes.html#Address) | |
| `FhirEx.Types.ContactPoint` | [ContactPoint](https://www.hl7.org/fhir/R5/datatypes.html#ContactPoint) | Phone, email, etc. |
| `FhirEx.Types.Period` | [Period](https://www.hl7.org/fhir/R5/datatypes.html#Period) | Start/end datetime range |
| `FhirEx.Types.Quantity` | [Quantity](https://www.hl7.org/fhir/R5/datatypes.html#Quantity) | Measured amount with UCUM unit |
| `FhirEx.Types.Range` | [Range](https://www.hl7.org/fhir/R5/datatypes.html#Range) | Low/high Quantity pair |
| `FhirEx.Types.Ratio` | [Ratio](https://www.hl7.org/fhir/R5/datatypes.html#Ratio) | Numerator/denominator (INR, titers) |
| `FhirEx.Types.Annotation` | [Annotation](https://www.hl7.org/fhir/R5/datatypes.html#Annotation) | Text note with author + time |
| `FhirEx.Types.Extension` | [Extension](https://www.hl7.org/fhir/R5/extensibility.html) | FHIR extensibility mechanism |
| `FhirEx.Types.Meta` | [Meta](https://www.hl7.org/fhir/R5/resource.html#Meta) | Version, profile, tags |
| `FhirEx.Types.Narrative` | [Narrative](https://www.hl7.org/fhir/R5/narrative.html) | XHTML human-readable summary |
## FHIR R5 notes
This library targets **FHIR R5**. Key differences from R4 that are reflected in the structs:
- `Encounter.class` is now `[CodeableConcept]` (was a single `Coding` in R4)
- `Encounter.hospitalization` has been renamed to `Encounter.admission`
- `Encounter.location.form` replaces `location.physicalType`
- `ServiceRequest.patientInstruction` is now a structured array
- `Observation` component validation requires `code` on each component
## Development
```sh
mix deps.get
mix test
mix docs # generate HTML documentation
```
The test suite has 180 tests covering struct construction, JSON round-trips, nil field omission, all polymorphic field variants, validation rules, and nested error path propagation.
## License
MIT