|
8 min read

@atomic-ehr/codegen: US Core Profiles in TypeScript

Summarize this blog post with:

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:

  1. generate profile classes for US Core Patient and US Core Blood Pressure from hl7.fhir.us.core@8.0.1,
  2. turn each row into a US Core Patient — typed extension setters and apply(),
  3. turn each row into a US Core Blood Pressure — typed slices, fixed LOINC, and validate(),
  4. package them as a Bundle,
  5. read the bundle back with typed getters to compute an average BP,
  6. post the bundle to a local Aidbox server.

Prerequisites

  • Node.js 20+ (or Bun) — you import @atomic-ehr/codegen as a library from a TypeScript script and run it with tsx or bun. 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:

  1. Build the plain Patient — profile-required (identifier, name) and must-support (gender, birthDate) fields as a typed R4 resource.
  2. Then USCorePatientProfile.apply(basePatient) stamps meta.profile and 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 nested extension[] 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, and address aren'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 code to LOINC 85354-9 ("Blood pressure panel"),
  • fixes a vital-signs category slice,
  • defines component[systolic] and component[diastolic] slices with specific LOINC discriminators (8480-6 and 8462-4),
  • requires an effectiveDateTime or effectivePeriod,
  • requires valueQuantity inside 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 stamps meta.profile, fills the fixed code (LOINC 85354-9), appends the vital-signs category slice, and adds empty component[systolic] / component[diastolic] stubs with discriminator codes already set.
  • setSystolic({ value, unit, ... }) fills the valueQuantity inside the systolic slice. The discriminator code field on that component is already there from create() — 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. Because Bundle and BundleEntry are generic over the contained resource (defaulting to Resource), Bundle<Patient | Observation> narrows entry[].resource to that union. That's the type-level half of the story; in Step 5 we'll layer profile-aware runtime narrowing on top with is().
  • References via urn:uuid. The patient's fullUrl is a UUID; the observation's subject.reference points at the same UUID. Reference.reference is typed as a union covering every FHIR literal reference form — Patient/${id}, absolute http://..., 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. Checks resourceType and meta.profile.includes(canonicalUrl); use it as a .filter() predicate on any collection. Nothing constructed, nothing validated.
  • from(obs) validates the survivors. Once is() 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 walking component[].code.coding[].code to 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

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.

GitHub | NPM | US Core IG | Aleksandr Penskoi on LinkedIn

Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

Get the latest articles on FHIR, interoperability, and healthcare IT.