|
8 Min. Lesezeit
|

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

Diesen Artikel zusammenfassen mit:
ChatGPTPerplexityClaudeGrok

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:

  1. Generieren Sie Profilklassen für US Core Patient und US Core Blood Pressure aus hl7.fhir.us.core@8.0.1,
  2. wandeln Sie jede Zeile in einen US Core Patient um — typisierte Extension-Setter und apply(),
  3. wandeln Sie jede Zeile in einen US Core Blood Pressure um — typisierte Slices, fester LOINC und validate(),
  4. verpacken Sie alles als Bundle,
  5. lesen Sie das Bundle mit typisierten Gettern zurück, um einen durchschnittlichen Blutdruck zu berechnen,
  6. senden Sie das Bundle an einen lokalen Aidbox-Server.

Voraussetzungen

  • Node.js 20+ (oder Bun) — Sie importieren @atomic-ehr/codegen als Bibliothek in einem TypeScript-Skript und führen es mit tsx oder bun aus. 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:

  1. Den einfachen Patient aufbauen — profilpflichtige (identifier, name) und Must-Support-Felder (gender, birthDate) als typisierte R4-Ressource.
  2. Dann USCorePatientProfile.apply(basePatient) stempelt meta.profile und 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 verschachtelte extension[]-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, birthDate und address werden 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 code auf LOINC 85354-9 („Blood pressure panel"),
  • fixiert einen vital-signs-Kategorie-Slice,
  • definiert die Slices component[systolic] und component[diastolic] mit spezifischen LOINC-Diskriminatoren (8480-6 und 8462-4),
  • erfordert ein effectiveDateTime oder effectivePeriod,
  • erfordert valueQuantity innerhalb 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 stempelt meta.profile, füllt den festen code (LOINC 85354-9), hängt den Vital-Signs-Kategorie-Slice an und fügt leere component[systolic]- / component[diastolic]-Stubs mit bereits gesetzten Diskriminatorcodes hinzu.
  • setSystolic({ value, unit, ... }) füllt die valueQuantity innerhalb des systolischen Slices. Das Diskriminatorfeld code für diese Komponente ist bereits durch create() 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. Da Bundle und BundleEntry generisch über die enthaltene Ressource sind (Standard: Resource), schränkt Bundle<Patient | Observation> entry[].resource auf diese Union ein. Das ist die typsystemseitige Hälfte der Geschichte; in Schritt 5 ergänzen wir sie mit is() durch profilbewusstes Laufzeit-Narrowing.
  • Referenzen per urn:uuid. Die fullUrl des Patienten ist eine UUID; das Feld subject.reference der Observation zeigt auf dieselbe UUID. Reference.reference ist als Union typisiert, die alle literalen FHIR-Referenzformen abdeckt — Patient/${id}, absolutes http://..., 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üft resourceType und meta.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. Sobald is() die Eingabe eingeschränkt hat, führt from() 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 von component[].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 die on-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). preprocessPackage ermöglicht es, diese beim Laden zu korrigieren, ohne das Paket zu forken — siehe on-the-fly/ccda's generate.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.

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

Diesen Artikel teilen
Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

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