Introducing @fhirfly-io/fhir-builder v1.0.0
Build valid FHIR R4 resources in TypeScript with a fluent API. Zero dependencies, 13 resource builders, build-time validation, and bundle reference resolution.

If you've ever hand-assembled FHIR JSON, you know the friction. Nested objects three levels deep. Code system URIs you have to look up every time. References that need urn:uuid rewriting for transaction bundles. One missing field and the FHIR server rejects the whole payload with a cryptic OperationOutcome.
Today we're releasing @fhirfly-io/fhir-builder v1.0.0 — a fluent FHIR R4 resource builder for TypeScript. Zero runtime dependencies. Works entirely offline. Apache 2.0 licensed.
The Problem
FHIR R4 is a powerful data model, but its JSON representation is verbose. Building a single Observation with LOINC coding, a component for systolic and diastolic values, and a reference to a Patient takes 40+ lines of hand-written JSON. Scale that to a transaction bundle with a Patient, Encounter, multiple Observations, a Condition, and medications — and you're looking at hundreds of lines where a single typo breaks everything.
Most teams solve this with internal helper functions that grow organically and never get tested. Others use heavyweight FHIR libraries that bring their own runtime, type system, and opinions about how you should structure your code.
We wanted something in between: a typed builder that produces plain FHIR JSON objects, validates at build time, and stays out of your way.
What You Get
13 Resource Builders
Each builder follows the same pattern — instantiate, chain setters, call .build():
import { FHIRBuilder } from '@fhirfly-io/fhir-builder';
const fb = new FHIRBuilder();
const patient = fb.patient()
.name('Jane', 'Doe')
.dob('1990-01-15')
.gender('female')
.mrn('12345', 'http://my-hospital.org/mrn')
.telecom('phone', '555-0100', 'home')
.address({ line: '123 Main St', city: 'Springfield', state: 'IL', postalCode: '62701' })
.build();
The result is a plain JavaScript object — valid FHIR R4 JSON. No wrapper classes, no serialization step. Pass it directly to JSON.stringify() or your HTTP client.
Supported resources: Patient, Observation, Condition, MedicationStatement, MedicationRequest, AllergyIntolerance, Immunization, Procedure, Encounter, Coverage, ExplanationOfBenefit, DiagnosticReport, and Bundle.
Build-Time Validation
In v1.0.0, calling .build() validates required FHIR fields before returning the resource. Missing a required reference? You get a ValidationError with structured details — not a silent invalid resource:
import { ConditionBuilder, ValidationError } from '@fhirfly-io/fhir-builder';
try {
new ConditionBuilder()
.icd10('E11.9', 'Type 2 diabetes')
.clinicalStatus('active')
.build(); // No subject set
} catch (err) {
if (err instanceof ValidationError) {
console.log(err.resourceType); // "Condition"
console.log(err.errors);
// [{ field: "subject", message: "subject is required", severity: "error" }]
}
}
Each builder validates the fields that FHIR R4 requires for that resource type. The goal is catching mistakes at construction time, not at submission time when the FHIR server gives you a 400 with minimal context.
Choice Type Safety
FHIR uses polymorphic fields — onset[x] can be onsetDateTime, onsetAge, onsetPeriod, or onsetString. In raw JSON, nothing stops you from accidentally setting both onsetDateTime and onsetAge on the same Condition. The FHIR server may accept it, ignore one silently, or reject it.
fhir-builder enforces mutual exclusion. Setting one variant automatically clears any previously set variant:
const condition = fb.condition()
.onsetAge(45, 'years') // sets onsetAge
.onsetDateTime('2020-06-15') // clears onsetAge, sets onsetDateTime
.subject('Patient/p1')
.icd10('E11.9', 'Type 2 diabetes')
.clinicalStatus('active')
.build();
// Result has onsetDateTime only — onsetAge was automatically removed
This applies to all choice types across all builders: onset[x], effective[x], medication[x], value[x], performed[x], occurrence[x], and abatement[x].
Code System Shortcuts
Instead of remembering URIs, use built-in shorthand methods:
// LOINC observation
const bp = fb.observation()
.loincCode('85354-9', 'Blood pressure panel')
.status('final')
.subject('Patient/p1')
.build();
// ICD-10 condition
const diabetes = fb.condition()
.icd10('E11.9', 'Type 2 diabetes mellitus')
.clinicalStatus('active')
.subject('Patient/p1')
.build();
// NDC medication
const med = fb.medicationStatement()
.medicationByNDC('0093-7214-01', 'Metformin 500mg')
.status('active')
.subject('Patient/p1')
.build();
Each shorthand sets both the code value and the correct system URI. For less common code systems, use the generic .code() method with CodeSystems constants:
import { CodeSystems } from '@fhirfly-io/fhir-builder';
fb.procedure()
.code('27687-0', CodeSystems.LOINC, 'Cytology report')
.subject('Patient/p1')
.build();
Bundle Reference Resolution
Building a transaction bundle means rewriting internal references to urn:uuid format. fhir-builder handles this automatically:
const patient = fb.patient().id('patient-1').name('Jane', 'Doe').gender('female').build();
const encounter = fb.encounter().id('enc-1').status('finished').encounterClass('AMB').subject('Patient/patient-1').build();
const observation = fb.observation().id('obs-1').loincCode('85354-9').subject('Patient/patient-1').encounter('Encounter/enc-1').valueQuantity(120, 'mmHg').build();
const bundle = fb.bundle('transaction')
.add(patient)
.add(encounter)
.add(observation)
.resolveReferences()
.build();
After .resolveReferences(), the observation's subject.reference is rewritten from Patient/patient-1 to urn:uuid:patient-1, matching the patient entry's fullUrl. Same for the encounter reference. This is the correct format for FHIR transaction bundles.
US Core Extensions
The Patient builder has built-in support for US Core profile extensions:
const patient = fb.patient()
.name('Maria', 'Garcia')
.gender('female')
.race('2106-3', undefined, 'White')
.ethnicity('2135-2', undefined, 'Hispanic or Latino')
.birthSex('F')
.build();
Using any US Core extension automatically adds the US Core Patient profile to meta.profile.
Optional Enrichment
fhir-builder produces structurally valid FHIR on its own — no API calls, no network required. But if you want display names, crosswalked codings, and richer metadata, pair it with @fhirfly-io/terminology:
import { FHIRBuilder } from '@fhirfly-io/fhir-builder';
import { Fhirfly } from '@fhirfly-io/terminology';
const fb = new FHIRBuilder();
const api = new Fhirfly({ apiKey: process.env.FHIRFLY_KEY });
const ndc = await api.ndc.lookup('0069-0151-01');
const med = fb.medicationStatement()
.medicationByNDC('0069-0151-01', ndc.display)
.addCoding(ndc.fhir_coding.rxnorm) // crosswalked RxNorm coding
.status('active')
.subject('Patient/p1')
.build();
Without enrichment: a valid NDC code with system URI. With enrichment: the drug's display name plus crosswalked RxNorm, SNOMED, and other codings attached automatically.
Key Takeaways
- Zero dependencies —
npm install @fhirfly-io/fhir-builderadds nothing to your dependency tree. No transitive supply chain risk. - Build-time validation —
build()throwsValidationErrorwith structured error details when required fields are missing. Catch mistakes before they reach the FHIR server. - Choice type safety — polymorphic FHIR fields (
onset[x],effective[x], etc.) enforce mutual exclusion automatically. - 13 resource types — Patient, Observation, Condition, Medications, Allergies, Immunizations, Procedures, Encounters, Coverage, EOB, DiagnosticReport, and Bundle.
- Apache 2.0 — use it in commercial projects, modify it, contribute back.
Get Started
npm install @fhirfly-io/fhir-builder
Written by The FHIRfly Team — a collective of healthcare data experts, AI specialists, and industry veterans building better clinical coding APIs.