---
{
  "title": "@atomic-ehr/codegen: US Core Profiles in TypeScript",
  "description": "Generate typed profile classes from the US Core IG with @atomic-ehr/codegen. Build compliant Patients and BP observations with typed factories, typed extensions and slices, profile-aware validation, type guards, and typed bundles.",
  "date": "2026-05-08",
  "author": "Aleksandr Penskoi",
  "reading-time": "8 minutes",
  "tags": [
    "FHIR Tools",
    "FHIR Standard",
    "Code Generation",
    "TypeScript",
    "Aidbox"
  ]
}
---
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`](https://github.com/atomic-ehr/codegen) makes that boilerplate disappear. Point it at the [US Core IG](https://www.hl7.org/fhir/us/core/) 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](https://www.hl7.org/fhir/us/core/StructureDefinition-us-core-patient.html) and [US Core Blood Pressure](https://www.hl7.org/fhir/us/core/StructureDefinition-us-core-blood-pressure.html).

## What You'll Build

A CSV-to-FHIR converter, built step by step:

1. generate profile classes for [US Core Patient](https://www.hl7.org/fhir/us/core/StructureDefinition-us-core-patient.html) and [US Core Blood Pressure](https://www.hl7.org/fhir/us/core/StructureDefinition-us-core-blood-pressure.html) 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:

```bash
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`:

```typescript
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:

```bash
$ 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`](https://github.com/Aidbox/examples/tree/main/developer-experience/atomic-ehr-codegen-typescript-us-core-profiles) — `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](https://github.com/atomic-ehr/codegen/tree/main/examples/typescript-us-core).

## 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](https://www.hl7.org/fhir/us/core/ValueSet-omb-race-category.html) US Core expects:

```csv
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.

<details>
<summary>`parseCsv(path: string): Row[]` in `load.ts` — boilerplate, click to expand</summary>

```typescript
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;
  });
};
```

</details>

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:

```typescript
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:

```typescript
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:

```typescript
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:

```bash
$ 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`:

```typescript
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})`);
```

```bash
$ 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](https://www.health-samurai.io/aidbox):

```bash
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:

```bash
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:

```bash
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:

```json
{
  "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`:

```bash
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`](https://github.com/atomic-ehr/codegen/blob/main/examples/typescript-us-core/profile-patient.test.ts), [`profile-bp.test.ts`](https://github.com/atomic-ehr/codegen/blob/main/examples/typescript-us-core/profile-bp.test.ts), [`profile-bodyweight.test.ts`](https://github.com/atomic-ehr/codegen/blob/main/examples/typescript-us-core/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 the [`on-the-fly`](https://github.com/atomic-ehr/codegen/tree/main/examples/on-the-fly) examples for [German KBV](https://github.com/atomic-ehr/codegen/tree/main/examples/on-the-fly/kbv-r4) and [Norwegian base profiles](https://github.com/atomic-ehr/codegen/tree/main/examples/on-the-fly/norge-r4).
- **Patch broken packages on the fly.** Real-world IGs ship with defects (typo'd canonicals, missing bindings). `preprocessPackage` lets you fix them at load time without forking the package — see [`typescript-ccda`'s `generate.ts`](https://github.com/atomic-ehr/codegen/blob/main/examples/typescript-ccda/generate.ts) repairing 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.

[GitHub](https://github.com/atomic-ehr/codegen) | [NPM](https://www.npmjs.com/package/@atomic-ehr/codegen) | [US Core IG](https://www.hl7.org/fhir/us/core/) | [Aleksandr Penskoi on LinkedIn](https://www.linkedin.com/in/aleksandr-penskoi/)
