Building US Core resources by hand is tedious. You stamp meta.profile, look up LOINC codes, hand-roll the us-core-race nested extension — every field is a typo waiting to happen, every profile is its own version of the same ceremony.
@atomic-ehr/codegen makes that boilerplate disappear. Point it at the US Core IG and you get one TypeScript class per profile, with typed accessors for fixed values, extensions, and slices, plus a validate() that knows what the profile requires.
This tutorial walks through that end-to-end on two US Core profiles: US Core Patient and US Core Blood Pressure.
What You'll Build
A CSV-to-FHIR converter, built step by step:
- generate profile classes for US Core Patient and US Core Blood Pressure from
hl7.fhir.us.core@8.0.1, - turn each row into a US Core Patient — typed extension setters and
apply(), - turn each row into a US Core Blood Pressure — typed slices, fixed LOINC, and
validate(), - package them as a Bundle,
- read the bundle back with typed getters to compute an average BP,
- post the bundle to a local Aidbox server.
Prerequisites
- Node.js 20+ (or Bun) — you import
@atomic-ehr/codegenas a library from a TypeScript script and run it withtsxorbun. The generated output is pure TypeScript with no runtime npm dependencies. - TypeScript 5+
- Basic familiarity with FHIR and US Core (knowing what "profile" and "slice" mean is enough)
Step 1 — Generate Profile Classes
Set up a fresh project:
mkdir ts-us-core-tutorial && cd ts-us-core-tutorial
npm init -y
npm install --save-dev @atomic-ehr/codegen tsx typescript
Create generate.ts:
import { APIBuilder, mkCodegenLogger, prettyReport } from "@atomic-ehr/codegen";
const main = async () => {
const logger = mkCodegenLogger({
suppressTags: ["#fieldTypeNotFound", "#duplicateSchema", "#duplicateCanonical", "#largeValueSet"],
});
const builder = new APIBuilder({ logger })
.fromPackage("hl7.fhir.us.core", "8.0.1")
.typeSchema({
treeShake: {
"hl7.fhir.us.core": {
"http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient": {},
"http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure": {},
},
"hl7.fhir.r4.core": {
"http://hl7.org/fhir/StructureDefinition/Bundle": {},
},
},
})
.typescript({
generateProfile: true,
})
.outputTo("./fhir-types")
.cleanOutput(true);
const report = await builder.generate();
console.log(prettyReport(report));
if (!report.success) process.exit(1);
};
main();
Two things matter here:
generateProfile: true — emit a wrapper class per profile with typed accessors for extensions, slices, and fixed values. Without it, only base R4 types.treeShake: { ... } — only the listed canonicals and their transitive deps are generated (~50 files instead of 250+).
Run it. prettyReport(report) prints a grouped summary so you see what got emitted without crawling the output dir:
$ npx tsx generate.ts
# Output trimmed for brevity
Generated files (12 kloc):
- fhir-types/hl7-fhir-r4-core/Bundle.ts (69 loc)
- fhir-types/hl7-fhir-r4-core/Observation.ts (112 loc)
- fhir-types/hl7-fhir-r4-core/Patient.ts (80 loc)
- fhir-types/hl7-fhir-us-core/profiles/Extension_USCoreRaceExtension.ts (265 loc)
- fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts (394 loc)
- fhir-types/hl7-fhir-us-core/profiles/Patient_USCorePatientProfile.ts (253 loc)
Duration: 6978ms
Status: 🟩 Success
The on-disk layout looks like this:
fhir-types/
├── hl7-fhir-r4-core/ # Base R4 types
│ ├── Bundle.ts
│ ├── Patient.ts
│ ├── Observation.ts
│ └── ...
├── hl7-fhir-us-core/
│ └── profiles/
│ ├── Patient_USCorePatientProfile.ts
│ ├── Observation_USCoreBloodPressureProfile.ts
│ ├── Extension_USCoreRaceExtension.ts
│ └── ...
└── profile-helpers.ts # Runtime helpers used by profile classes
The full tutorial code lives in Aidbox/examples — generate.ts, load.ts, avg.ts, the CSV, and the committed fhir-types/ so you can browse the generated code without running the generator. For broader profile-API exploration, the codegen repo also has a typescript-us-core test example.
Step 2 — Row to a US Core Patient
The input is patients.csv — basic demographics plus one BP reading per patient. Race uses the OMB-category codes US Core expects:
mrn,family,given,birthDate,gender,raceCode,raceDisplay,effectiveDateTime,systolic,diastolic
MRN-001,Lovelace,Ada,1815-12-10,female,2106-3,White,2026-04-15,120,80
MRN-002,Turing,Alan,1912-06-23,male,2106-3,White,2026-04-15,118,76
MRN-003,Curie,Marie,1867-11-07,female,2106-3,White,2026-04-16,125,82
MRN-004,Carver,George,1864-01-01,male,2054-5,Black or African American,2026-04-16,135,88
MRN-005,Ochoa,Ellen,1958-05-10,female,2054-5,Black or African American,2026-04-17,128,84
A trivial parser hands each row over as plain strings; all type narrowing and numeric parsing happens later, at the point where we hand values to typed profile setters.
parseCsv(path: string): Row[] in load.ts — boilerplate, click to expand
import { readFileSync } from "node:fs";
type Row = {
mrn: string;
family: string;
given: string;
birthDate: string;
gender: string;
raceCode: string;
raceDisplay: string;
effectiveDateTime: string;
systolic: string;
diastolic: string;
};
const parseCsv = (path: string): Row[] => {
const [header, ...lines] = readFileSync(path, "utf8").trim().split("\n");
const cols = header!.split(",");
return lines.map(line => {
const values = line.split(",");
return Object.fromEntries(cols.map((c, i) => [c, values[i]])) as Row;
});
};
That's the boring half. The interesting half is turning each Row into a US Core Patient — the profile adds a handful of extensions and bumps identifier and name to required. The generated class has typed setters for all of them:
import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient";
import { USCorePatientProfile } from "./fhir-types/hl7-fhir-us-core/profiles";
const rowToPatient = (row: Row): USCorePatientProfile => {
const basePatient: Patient = {
resourceType: "Patient",
identifier: [{ system: "http://hospital.example.org/mrn", value: row.mrn }],
name: [{ family: row.family, given: [row.given] }],
gender: row.gender as Patient["gender"],
birthDate: row.birthDate,
};
const patient = USCorePatientProfile.apply(basePatient);
patient.setRace({
ombCategory: { system: "urn:oid:2.16.840.1.113883.6.238", code: row.raceCode, display: row.raceDisplay },
text: row.raceDisplay,
});
return patient;
};
Two phases:
- Build the plain
Patient — profile-required (identifier,name) and must-support (gender,birthDate) fields as a typed R4 resource. - Then
USCorePatientProfile.apply(basePatient)stampsmeta.profileand returns a profile instance with typed accessors for the US Core extensions.
Two notes on what the profile API does for you:
- Three extension setter forms.
setRace({ ombCategory, text })takes flat input and generates the nestedextension[]plumbing. It also accepts a typed profile instance or a raw FHIR Extension for pass-through. - No setters for must-support base fields.
gender,birthDate, andaddressaren't profiled further by US Core, so the profile class emits no.setGender()-style wrappers — populate them as normal Patient fields.validate()still warns if a must-support field is missing.
Step 3 — Row to a US Core Blood Pressure
The BP profile is where codegen really earns its keep. The US Core Blood Pressure profile:
- fixes
codeto LOINC 85354-9 ("Blood pressure panel"), - fixes a
vital-signscategory slice, - defines
component[systolic]andcomponent[diastolic]slices with specific LOINC discriminators (8480-6 and 8462-4), - requires an
effectiveDateTimeoreffectivePeriod, - requires
valueQuantityinside each slice.
Hand-rolling that per row is the kind of thing codegen eliminates. The generated class collapses it to three setters:
import { USCoreBloodPressureProfile } from "./fhir-types/hl7-fhir-us-core/profiles";
const rowToBP = (row: Row, patientRef: `urn:uuid:${string}`): USCoreBloodPressureProfile => {
const bp = USCoreBloodPressureProfile.create({
status: "final",
subject: { reference: patientRef },
});
bp
.setEffectiveDateTime(row.effectiveDateTime)
.setSystolic({ value: Number(row.systolic), unit: "mmHg", system: "http://unitsofmeasure.org", code: "mm[Hg]" })
.setDiastolic({ value: Number(row.diastolic), unit: "mmHg", system: "http://unitsofmeasure.org", code: "mm[Hg]" });
const { errors } = bp.validate();
if (errors.length) throw new Error(`${row.mrn}: ${errors.join("; ")}`);
return bp;
};
What happens behind the scenes:
create()does the ceremony. It stampsmeta.profile, fills the fixedcode(LOINC 85354-9), appends the vital-signs category slice, and adds emptycomponent[systolic]/component[diastolic]stubs with discriminator codes already set.setSystolic({ value, unit, ... })fills thevalueQuantityinside the systolic slice. The discriminatorcodefield on that component is already there fromcreate() — you only supply the reading.validate()returns{ errors, warnings }. Errors block (required fields, excluded fields, disallowed choice variants, slice cardinality). Warnings surface must-support concerns. A malformed row fails fast with the MRN — you don't discover it at POST time.
You didn't type the discriminator codes. You didn't remember 85354-9. The only codes in your source are the ones the profile doesn't dictate — and for BP, there aren't any.
Step 4 — Assemble the Bundle
Each row produces a Patient and a BP Observation linked by the Patient's urn:uuid placeholder. Package them as transaction entries:
import { writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import type { Bundle, BundleEntry } from "./fhir-types/hl7-fhir-r4-core/Bundle";
import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient";
import type { Observation } from "./fhir-types/hl7-fhir-r4-core/Observation";
const rowToEntries = (row: Row): BundleEntry<Patient | Observation>[] => {
const patientUrn: `urn:uuid:${string}` = `urn:uuid:${randomUUID()}`;
const patient = rowToPatient(row);
const bp = rowToBP(row, patientUrn);
return [
{ fullUrl: patientUrn, resource: patient.toResource(), request: { method: "POST", url: "Patient" } },
{ fullUrl: `urn:uuid:${randomUUID()}`, resource: bp.toResource(), request: { method: "POST", url: "Observation" } },
];
};
const rows = parseCsv("./patients.csv");
console.log(`Loaded ${rows.length} rows`);
const bundle: Bundle<Patient | Observation> = {
resourceType: "Bundle",
type: "transaction",
entry: rows.flatMap(rowToEntries),
};
writeFileSync("./bundle.json", JSON.stringify(bundle, null, 2));
console.log(`Wrote bundle with ${bundle.entry!.length} entries`);
Run the full loader:
$ npx tsx load.ts
# Loaded 5 rows
# Wrote bundle with 10 entries
Worth noticing:
Bundle<T>carries through. BecauseBundleandBundleEntryare generic over the contained resource (defaulting toResource),Bundle<Patient | Observation>narrowsentry[].resourceto that union. That's the type-level half of the story; in Step 5 we'll layer profile-aware runtime narrowing on top withis().- References via
urn:uuid. The patient'sfullUrlis a UUID; the observation'ssubject.referencepoints at the same UUID.Reference.referenceis typed as a union covering every FHIR literal reference form —Patient/${id}, absolutehttp://...,urn:uuid:...,urn:oid:..., and#fragment — so the placeholder drops in without a cast. On transaction commit, the server resolves both UUIDs to real resource IDs atomically.
Step 5 — Read Back: Average BP from the Bundle
Writing is half the story. Read bundle.json back and compute the average systolic/diastolic to exercise the read-side API in avg.ts:
import { readFileSync } from "node:fs";
import type { Bundle } from "./fhir-types/hl7-fhir-r4-core/Bundle";
import type { Observation } from "./fhir-types/hl7-fhir-r4-core/Observation";
import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient";
import { USCoreBloodPressureProfile } from "./fhir-types/hl7-fhir-us-core/profiles";
const bundle: Bundle<Patient | Observation> = JSON.parse(readFileSync("./bundle.json", "utf8"));
const bps = (bundle.entry ?? [])
.map(e => e.resource)
.filter(USCoreBloodPressureProfile.is)
.map(o => USCoreBloodPressureProfile.from(o));
const avg = (xs: number[]) => xs.reduce((s, x) => s + x, 0) / xs.length;
const systolic = bps.map(bp => bp.getSystolic()!.value!);
const diastolic = bps.map(bp => bp.getDiastolic()!.value!);
console.log(`Avg BP: ${avg(systolic).toFixed(1)}/${avg(diastolic).toFixed(1)} mmHg (n=${bps.length})`);
$ npx tsx avg.ts
Avg BP: 125.2/82.0 mmHg (n=5)
Three things the profile does here:
is()is a type guard. ChecksresourceTypeandmeta.profile.includes(canonicalUrl); use it as a.filter()predicate on any collection. Nothing constructed, nothing validated.from(obs)validates the survivors. Onceis()has narrowed the input,from()runs the structural check (required fields, slice cardinality) and throws if a resource that claims the profile is malformed — so a broken bundle fails at read time, not on the next field access.getSystolic()/getDiastolic()return flat slices. No walkingcomponent[].code.coding[].codeto match LOINC codes. The profile already knows which slice is which.
That's the round-trip: CSV → typed profiles → validated Bundle → typed read-back with profile-aware getters. The same handful of lines would process BPs fetched from a FHIR server, loaded from a file, or received on a Subscription — the typed profile is the common shape, no matter the source.
Step 6 — Land Your Bundle on a FHIR Server
The typed pipeline is only half the story. To actually see the transaction commit — patient IDs assigned, urn:uuid references rewritten, resources stored and searchable — you need a FHIR server. Spin up and run Aidbox:
curl -JO https://aidbox.app/runme && docker compose up
Open http://localhost:8080 in your browser to grab a free developer license, then verify the FHIR endpoint is up:
curl -u "root:$(awk '/BOX_ROOT_CLIENT_SECRET:/{print $2}' docker-compose.yaml)" http://localhost:8080/fhir/metadata
You should see a JSON CapabilityStatement.
Send the bundle.json you just wrote:
curl -u "root:$(awk '/BOX_ROOT_CLIENT_SECRET:/{print $2}' docker-compose.yaml)" -X POST \
-H "Content-Type: application/fhir+json" \
-d @bundle.json http://localhost:8080/fhir
Aidbox returns a transaction-response bundle — one entry per input, each with a 201 Created and a location pointing at the stored resource:
{
"resourceType": "Bundle",
"type": "transaction-response",
"entry": [
{ "response": { "status": "201 Created", "location": "Patient/<id>/_history/1" } },
{ "response": { "status": "201 Created", "location": "Observation/<id>/_history/1" } },
...
]
}
Query an observation back and look at its subject:
curl -u "root:$(awk '/BOX_ROOT_CLIENT_SECRET:/{print $2}' docker-compose.yaml)" \
"http://localhost:8080/fhir/Observation?code=http://loinc.org|85354-9" \
| jq '.entry[].resource.subject.reference'
# "Patient/01J..."
# "Patient/01J..."
# "Patient/01J..."
No urn:uuid — Aidbox rewrote the placeholders atomically on commit.
Where To Go Next
- More of the profile API. Other factories, getters, and slice/extension forms are exercised in the codegen example tests:
profile-patient.test.ts,profile-bp.test.ts,profile-bodyweight.test.ts. - Mix profiles from multiple packages.
APIBuilder.fromPackage()can be chained — US Core alongside your custom IG, or alongside a regional base. See theon-the-flyexamples for German KBV and Norwegian base profiles. - Patch broken packages on the fly. Real-world IGs ship with defects (typo'd canonicals, missing bindings).
preprocessPackagelets you fix them at load time without forking the package — seetypescript-ccda'sgenerate.tsrepairing a typo'd CDA canonical.
Wrap Up
The generator emits both the base R4 types and a thin profile-class layer on top — no runtime DSL, no ORM, no framework. toResource() always gives you a plain FHIR resource you can send to any server.
@atomic-ehr/codegen is MIT-licensed; issues and PRs welcome.




