US Core-Ressourcen von Hand zu erstellen ist mühsam. Man stempelt meta.profile, schlägt LOINC-Codes nach, schreibt die verschachtelte Extension us-core-race manuell — jedes Feld ist ein Tippfehler, der darauf wartet, zu passieren, und jedes Profil ist seine eigene Version desselben Zeremoniells.
@atomic-ehr/codegen lässt diesen Boilerplate verschwinden. Richten Sie es auf das US Core IG aus, und Sie erhalten eine TypeScript-Klasse pro Profil mit typisierten Accessors für feste Werte, Extensions und Slices sowie ein validate(), das weiß, was das Profil erfordert.
Dieses Tutorial führt Sie anhand zweier US Core-Profile Schritt für Schritt durch: US Core Patient und US Core Blood Pressure.
Was Sie erstellen werden
Ein CSV-zu-FHIR-Konverter, Schritt für Schritt aufgebaut:
- Generieren Sie Profilklassen für US Core Patient und US Core Blood Pressure aus
hl7.fhir.us.core@8.0.1, - wandeln Sie jede Zeile in einen US Core Patient um — typisierte Extension-Setter und
apply(), - wandeln Sie jede Zeile in einen US Core Blood Pressure um — typisierte Slices, fester LOINC und
validate(), - verpacken Sie alles als Bundle,
- lesen Sie das Bundle mit typisierten Gettern zurück, um einen durchschnittlichen Blutdruck zu berechnen,
- senden Sie das Bundle an einen lokalen Aidbox-Server.
Voraussetzungen
- Node.js 20+ (oder Bun) — Sie importieren
@atomic-ehr/codegenals Bibliothek in einem TypeScript-Skript und führen es mittsxoderbunaus. Die generierte Ausgabe ist reines TypeScript ohne Laufzeit-npm-Abhängigkeiten. - TypeScript 5+
- Grundlegende Kenntnisse von FHIR und US Core (es genügt zu wissen, was „Profil" und „Slice" bedeuten)
Schritt 1 — Profilklassen generieren
Legen Sie ein neues Projekt an:
mkdir ts-us-core-tutorial && cd ts-us-core-tutorial
npm init -y
npm install --save-dev @atomic-ehr/codegen tsx typescript
Erstellen Sie 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();
Zwei Dinge sind hier wichtig:
generateProfile: true — gibt pro Profil eine Wrapper-Klasse mit typisierten Accessors für Extensions, Slices und feste Werte aus. Ohne diese Option werden nur Basis-R4-Typen generiert.treeShake: { ... } — nur die aufgeführten Canonicals und ihre transitiven Abhängigkeiten werden generiert (~50 Dateien statt 250+).
Führen Sie es aus. prettyReport(report) gibt eine gruppierte Zusammenfassung aus, sodass Sie sehen, was erzeugt wurde, ohne das Ausgabeverzeichnis durchsuchen zu müssen:
$ 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
Die Verzeichnisstruktur auf der Festplatte sieht folgendermaßen aus:
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
Der vollständige Tutorial-Code befindet sich in Aidbox/examples — generate.ts, load.ts, avg.ts, die CSV-Datei und das eingecheckte fhir-types/-Verzeichnis, damit Sie den generierten Code durchstöbern können, ohne den Generator ausführen zu müssen. Für eine weitergehende Erkundung der Profil-API enthält das Codegen-Repository außerdem ein typescript-r4-us-core-Testbeispiel.
Schritt 2 — Zeile zu einem US Core Patient
Die Eingabe ist patients.csv — grundlegende demografische Daten plus eine Blutdruckmessung pro Patient. Die Rasseangabe verwendet die OMB-Kategoriecodes, die US Core erwartet:
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
Ein einfacher Parser übergibt jede Zeile als reine Zeichenketten; alle Typeinschränkungen und numerischen Konvertierungen erfolgen später, an dem Punkt, an dem wir Werte an typisierte Profil-Setter übergeben.
parseCsv(path: string): Row[] in load.ts — Boilerplate, zum Erweitern klicken
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;
});
};
Das ist die langweilige Hälfte. Die interessante Hälfte ist die Umwandlung jeder Row in einen US Core Patient — das Profil ergänzt einige Extensions und macht identifier und name zu Pflichtfeldern. Die generierte Klasse verfügt über typisierte Setter für alle davon:
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;
};
Zwei Phasen:
- Den einfachen
Patientaufbauen — profilpflichtige (identifier,name) und Must-Support-Felder (gender,birthDate) als typisierte R4-Ressource. - Dann
USCorePatientProfile.apply(basePatient)stempeltmeta.profileund gibt eine Profilinstanz mit typisierten Accessors für die US Core-Extensions zurück.
Zwei Anmerkungen zu dem, was die Profil-API für Sie erledigt:
- Drei Formen des Extension-Setters.
setRace({ ombCategory, text })nimmt eine flache Eingabe entgegen und erzeugt die verschachtelteextension[]-Struktur. Es akzeptiert außerdem eine typisierte Profilinstanz oder eine rohe FHIR-Extension für die Durchleitung. - Keine Setter für Must-Support-Basisfelder.
gender,birthDateundaddresswerden von US Core nicht weiter profiliert, sodass die Profilklasse keine.setGender()-ähnlichen Wrapper ausgibt — befüllen Sie diese wie normale Patient-Felder.validate()gibt dennoch eine Warnung aus, wenn ein Must-Support-Feld fehlt.
Schritt 3 — Zeile zu einem US Core Blood Pressure
Das Blutdruckprofil ist der Bereich, in dem Codegen seinen wahren Wert zeigt. Das US Core Blood Pressure-Profil:
- fixiert
codeauf LOINC 85354-9 („Blood pressure panel"), - fixiert einen
vital-signs-Kategorie-Slice, - definiert die Slices
component[systolic]undcomponent[diastolic]mit spezifischen LOINC-Diskriminatoren (8480-6 und 8462-4), - erfordert ein
effectiveDateTimeodereffectivePeriod, - erfordert
valueQuantityinnerhalb jedes Slices.
Dies für jede Zeile von Hand zu schreiben ist genau das, was Codegen überflüssig macht. Die generierte Klasse reduziert es auf drei Setter:
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;
};
Was im Hintergrund passiert:
create()übernimmt das Zeremoniell. Es stempeltmeta.profile, füllt den festencode(LOINC 85354-9), hängt den Vital-Signs-Kategorie-Slice an und fügt leerecomponent[systolic]- /component[diastolic]-Stubs mit bereits gesetzten Diskriminatorcodes hinzu.setSystolic({ value, unit, ... })füllt dievalueQuantityinnerhalb des systolischen Slices. Das Diskriminatorfeldcodefür diese Komponente ist bereits durchcreate()gesetzt — Sie geben lediglich den Messwert an.validate()gibt{ errors, warnings }zurück. Fehler blockieren die Verarbeitung (Pflichtfelder, ausgeschlossene Felder, unzulässige Choice-Varianten, Slice-Kardinalität). Warnungen weisen auf Must-Support-Probleme hin. Eine fehlerhafte Zeile schlägt sofort mit der MRN fehl — Sie bemerken das Problem nicht erst beim POST.
Sie haben die Diskriminatorcodes nicht eingetippt. Sie mussten sich 85354-9 nicht merken. Die einzigen Codes in Ihrem Quellcode sind die, die das Profil nicht vorschreibt — und beim Blutdruck gibt es keine davon.
Schritt 4 — Das Bundle zusammenstellen
Jede Zeile erzeugt einen Patienten und eine Blutdruck-Observation, die durch den urn:uuid-Platzhalter des Patienten verknüpft sind. Verpacken Sie sie als Transaktions-Einträge:
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`);
Führen Sie den vollständigen Loader aus:
$ npx tsx load.ts
# Loaded 5 rows
# Wrote bundle with 10 entries
Erwähnenswert:
Bundle<T>wird weitergereicht. DaBundleundBundleEntrygenerisch über die enthaltene Ressource sind (Standard:Resource), schränktBundle<Patient | Observation>entry[].resourceauf diese Union ein. Das ist die typsystemseitige Hälfte der Geschichte; in Schritt 5 ergänzen wir sie mitis()durch profilbewusstes Laufzeit-Narrowing.- Referenzen per
urn:uuid. DiefullUrldes Patienten ist eine UUID; das Feldsubject.referenceder Observation zeigt auf dieselbe UUID.Reference.referenceist als Union typisiert, die alle literalen FHIR-Referenzformen abdeckt —Patient/${id}, absoluteshttp://...,urn:uuid:...,urn:oid:...und#fragment — sodass der Platzhalter ohne Cast eingesetzt werden kann. Bei der Transaktionsübertragung löst der Server beide UUIDs atomar in echte Ressourcen-IDs auf.
Schritt 5 — Zurücklesen: Durchschnittlicher Blutdruck aus dem Bundle
Das Schreiben ist nur die halbe Geschichte. Lesen Sie bundle.json zurück und berechnen Sie den durchschnittlichen systolischen/diastolischen Wert, um die Lese-API in avg.ts zu erproben:
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)
Drei Dinge, die das Profil hier leistet:
is()ist ein Type Guard. PrüftresourceTypeundmeta.profile.includes(canonicalUrl); verwenden Sie es als.filter()-Prädikat auf beliebigen Sammlungen. Es wird nichts konstruiert und nichts validiert.from(obs)validiert die Überlebenden. Sobaldis()die Eingabe eingeschränkt hat, führtfrom()die Strukturprüfung durch (Pflichtfelder, Slice-Kardinalität) und wirft einen Fehler, wenn eine Ressource, die das Profil beansprucht, fehlerhaft ist — so schlägt ein beschädigtes Bundle beim Lesen fehl, nicht beim nächsten Feldzugriff.getSystolic()/getDiastolic()geben flache Slices zurück. Kein Durchlaufen voncomponent[].code.coding[].code, um LOINC-Codes abzugleichen. Das Profil weiß bereits, welcher Slice welcher ist.
Das ist der vollständige Durchlauf: CSV → typisierte Profile → validiertes Bundle → typisiertes Zurücklesen mit profilbewussten Gettern. Dieselben wenigen Zeilen würden Blutdruckwerte verarbeiten, die von einem FHIR-Server abgerufen, aus einer Datei geladen oder über eine Subscription empfangen wurden — das typisierte Profil ist die gemeinsame Form, unabhängig von der Quelle.
Schritt 6 — Das Bundle auf einem FHIR-Server speichern
Die typisierte Pipeline ist nur die halbe Geschichte. Um die Transaktion tatsächlich zu committen — Patienten-IDs zuzuweisen, urn:uuid-Referenzen umzuschreiben, Ressourcen zu speichern und durchsuchbar zu machen — benötigen Sie einen FHIR-Server. Starten Sie Aidbox:
curl -JO https://aidbox.app/runme && docker compose up
Öffnen Sie http://localhost:8080 in Ihrem Browser, um eine kostenlose Entwicklerlizenz zu erhalten, und überprüfen Sie dann, ob der FHIR-Endpunkt verfügbar ist:
curl -u "root:$(awk '/BOX_ROOT_CLIENT_SECRET:/{print $2}' docker-compose.yaml)" http://localhost:8080/fhir/metadata
Sie sollten ein JSON-CapabilityStatement sehen.
Senden Sie das soeben erstellte bundle.json:
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 gibt ein transaction-response-Bundle zurück — einen Eintrag pro Eingabe, jeweils mit 201 Created und einem location-Header, der auf die gespeicherte Ressource zeigt:
{
"resourceType": "Bundle",
"type": "transaction-response",
"entry": [
{ "response": { "status": "201 Created", "location": "Patient/<id>/_history/1" } },
{ "response": { "status": "201 Created", "location": "Observation/<id>/_history/1" } },
...
]
}
Rufen Sie eine Observation ab und betrachten Sie ihr subject-Feld:
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..."
Kein urn:uuid mehr — Aidbox hat die Platzhalter beim Commit atomar umgeschrieben.
Wie es weitergeht
- Mehr von der Profil-API. Weitere Factories, Getter sowie Slice- und Extension-Formen werden in den Codegen-Beispieltests erprobt:
profile-us-core-patient.test.ts,profile-us-core-bp.test.ts,profile-us-core-bodyweight.test.ts. - Profile aus mehreren Paketen kombinieren.
APIBuilder.fromPackage()kann verkettet werden — US Core zusammen mit Ihrem eigenen IG oder einem regionalen Basisprofil. Siehe dieon-the-fly-Beispiele für German KBV und Norwegian base profiles. - Fehlerhafte Pakete zur Laufzeit patchen. Reale IGs werden mit Fehlern ausgeliefert (falsch geschriebene Canonicals, fehlende Bindings).
preprocessPackageermöglicht es, diese beim Laden zu korrigieren, ohne das Paket zu forken — sieheon-the-fly/ccda'sgenerate.ts, das einen falsch geschriebenen CDA-Canonical repariert.
Fazit
Der Generator gibt sowohl die Basis-R4-Typen als auch eine schlanke Profilklassen-Schicht darüber aus — keine Laufzeit-DSL, kein ORM, kein Framework. toResource() liefert stets eine einfache FHIR-Ressource, die Sie an jeden beliebigen Server senden können.
@atomic-ehr/codegen ist MIT-lizenziert; Issues und Pull Requests sind willkommen.





