Créer des ressources US Core à la main est fastidieux. On estampille meta.profile, on cherche les codes LOINC, on assemble à la main l'extension imbriquée us-core-race — chaque champ est une coquille en attente, chaque profil répète le même rituel.
@atomic-ehr/codegen fait disparaître ce code répétitif. Pointez-le vers le US Core IG et vous obtenez une classe TypeScript par profil, avec des accesseurs typés pour les valeurs fixes, les extensions et les tranches, ainsi qu'un validate() qui connaît les exigences du profil.
Ce tutoriel parcourt le processus de bout en bout pour deux profils US Core : US Core Patient et US Core Blood Pressure.
Ce que vous allez construire
Un convertisseur CSV vers FHIR, élaboré étape par étape :
- générer des classes de profils pour US Core Patient et US Core Blood Pressure à partir de
hl7.fhir.us.core@8.0.1, - transformer chaque ligne en US Core Patient — accesseurs d'extensions typés et
apply(), - transformer chaque ligne en US Core Blood Pressure — tranches typées, LOINC fixe et
validate(), - les regrouper dans un Bundle,
- relire le lot avec des accesseurs typés pour calculer une PA moyenne,
- envoyer le lot à un serveur Aidbox local.
Prérequis
- Node.js 20+ (ou Bun) — vous importez
@atomic-ehr/codegenen tant que bibliothèque depuis un script TypeScript et l'exécutez avectsxoubun. Le résultat généré est du TypeScript pur sans dépendances npm à l'exécution. - TypeScript 5+
- Familiarité de base avec FHIR et US Core (savoir ce que signifient « profil » et « tranche » suffit)
Étape 1 — Générer les classes de profils
Initialisez un nouveau projet :
mkdir ts-us-core-tutorial && cd ts-us-core-tutorial
npm init -y
npm install --save-dev @atomic-ehr/codegen tsx typescript
Créez 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();
Deux éléments importants ici :
generateProfile: true— émet une classe enveloppante par profil avec des accesseurs typés pour les extensions, les tranches et les valeurs fixes. Sans cette option, seuls les types R4 de base sont générés.treeShake: { ... }— seuls les canoniques listés et leurs dépendances transitives sont générés (~50 fichiers au lieu de 250+).
Exécutez-le. prettyReport(report) affiche un résumé groupé pour voir ce qui a été émis sans parcourir le répertoire de sortie :
$ npx tsx generate.ts
# Sortie abrégée pour plus de clarté
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
La structure sur disque ressemble à ceci :
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
Le code complet du tutoriel se trouve dans Aidbox/examples — generate.ts, load.ts, avg.ts, le CSV et le répertoire fhir-types/ archivé pour que vous puissiez parcourir le code généré sans exécuter le générateur. Pour une exploration plus large de l'API de profils, le dépôt de codegen propose également un exemple de test typescript-r4-us-core.
Étape 2 — Transformer une ligne en US Core Patient
L'entrée est patients.csv — données démographiques de base plus une lecture de PA par patient. La race utilise les codes de catégorie OMB attendus par US Core :
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
Un analyseur rudimentaire transmet chaque ligne sous forme de chaînes simples ; tout le rétrécissement de type et l'analyse numérique s'effectuent ensuite, au moment où l'on passe les valeurs aux accesseurs de profil typés.
parseCsv(path: string): Row[] dans load.ts — code répétitif, cliquez pour développer
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;
});
};
C'est la partie banale. La partie intéressante consiste à transformer chaque Row en US Core Patient — le profil ajoute quelques extensions et rend identifier et name obligatoires. La classe générée dispose d'accesseurs typés pour tous ces éléments :
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;
};
Deux phases :
- Construire le
Patientbrut — les champs requis par le profil (identifier,name) et à support obligatoire (gender,birthDate) comme ressource R4 typée. - Puis
USCorePatientProfile.apply(basePatient)estampillemeta.profileet retourne une instance de profil avec des accesseurs typés pour les extensions US Core.
Deux remarques sur ce que l'API de profil fait pour vous :
- Trois formes d'accesseurs d'extension.
setRace({ ombCategory, text })prend une entrée à plat et génère la plomberieextension[]imbriquée. Elle accepte également une instance de profil typée ou une Extension FHIR brute pour un passage direct. - Pas d'accesseurs pour les champs de base à support obligatoire.
gender,birthDateetaddressne sont pas davantage profilés par US Core, donc la classe de profil n'émet pas d'enveloppeurs de style.setGender()— renseignez-les comme des champs Patient normaux.validate()avertit tout de même si un champ à support obligatoire est absent.
Étape 3 — Transformer une ligne en US Core Blood Pressure
Le profil PA est là où le codegen se distingue vraiment. Le profil US Core Blood Pressure :
- fixe
codeau LOINC 85354-9 (« panneau de pression artérielle »), - fixe une tranche de catégorie
vital-signs, - définit des tranches
component[systolic]etcomponent[diastolic]avec des discriminateurs LOINC spécifiques (8480-6 et 8462-4), - exige un
effectiveDateTimeoueffectivePeriod, - exige un
valueQuantitydans chaque tranche.
Assembler tout cela à la main par ligne est exactement le genre de travail qu'élimine le codegen. La classe générée réduit tout à trois accesseurs :
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;
};
Ce qui se passe en coulisses :
create()accomplit le rituel. Il estampillemeta.profile, remplit lecodefixe (LOINC 85354-9), ajoute la tranche de catégorie vital-signs et insère des ébauches videscomponent[systolic]/component[diastolic]avec les codes discriminateurs déjà définis.setSystolic({ value, unit, ... })remplit levalueQuantitydans la tranche systolique. Le champcodediscriminateur sur ce composant est déjà présent depuiscreate()— vous ne fournissez que la lecture.validate()retourne{ errors, warnings }. Les erreurs bloquent (champs requis, champs exclus, variantes de choix non autorisées, cardinalité des tranches). Les avertissements signalent les problèmes de support obligatoire. Une ligne malformée échoue rapidement avec le NIM — vous ne le découvrez pas au moment du POST.
Vous n'avez pas saisi les codes discriminateurs. Vous n'avez pas eu à mémoriser 85354-9. Les seuls codes dans votre source sont ceux que le profil ne dicte pas — et pour la PA, il n'y en a aucun.
Étape 4 — Assembler le Bundle
Chaque ligne produit un Patient et une Observation de PA liés par le urn:uuid de substitution du Patient. Regroupez-les comme entrées de transaction :
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`);
Exécutez le chargeur complet :
$ npx tsx load.ts
# Loaded 5 rows
# Wrote bundle with 10 entries
Quelques points à noter :
Bundle<T>se propage. Parce queBundleetBundleEntrysont génériques sur la ressource contenue (avecResourcepar défaut),Bundle<Patient | Observation>restreintentry[].resourceà cette union. C'est la moitié typée de l'histoire ; à l'étape 5, nous ajouterons par-dessus un rétrécissement à l'exécution tenant compte des profils avecis().- Références via
urn:uuid. LefullUrldu patient est un UUID ; lesubject.referencede l'observation pointe vers le même UUID.Reference.referenceest typé comme une union couvrant toutes les formes de référence littérale FHIR —Patient/${id}, absoluehttp://...,urn:uuid:...,urn:oid:...et#fragment— donc le substitut s'insère sans transtypage. Lors de la validation de la transaction, le serveur résout les deux UUID en identifiants de ressources réels de manière atomique.
Étape 5 — Relecture : PA moyenne à partir du Bundle
Écrire n'est que la moitié de l'histoire. Relisez bundle.json et calculez la PA systolique/diastolique moyenne pour exercer l'API de lecture dans 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)
Trois choses que fait le profil ici :
is()est une garde de type. VérifieresourceTypeetmeta.profile.includes(canonicalUrl); utilisez-le comme prédicat.filter()sur n'importe quelle collection. Rien n'est construit, rien n'est validé.from(obs)valide les survivants. Une fois queis()a restreint l'entrée,from()effectue la vérification structurelle (champs requis, cardinalité des tranches) et lève une exception si une ressource qui revendique le profil est malformée — ainsi un lot défectueux échoue à la lecture, pas au prochain accès de champ.getSystolic()/getDiastolic()retournent des tranches à plat. Plus besoin de parcourircomponent[].code.coding[].codepour faire correspondre les codes LOINC. Le profil sait déjà quelle tranche est laquelle.
Voilà le cycle complet : CSV → profils typés → Bundle validé → relecture typée avec des accesseurs tenant compte des profils. Ces quelques lignes identiques traiteraient des PA récupérées depuis un serveur FHIR, chargées depuis un fichier ou reçues sur un Subscription — le profil typé est la forme commune, quelle que soit la source.
Étape 6 — Déposer votre Bundle sur un serveur FHIR
Le pipeline typé n'est que la moitié de l'histoire. Pour voir effectivement la transaction validée — identifiants de patients attribués, références urn:uuid réécrites, ressources stockées et interrogeables — vous avez besoin d'un serveur FHIR. Démarrez et exécutez Aidbox :
curl -JO https://aidbox.app/runme && docker compose up
Ouvrez http://localhost:8080 dans votre navigateur pour obtenir une licence développeur gratuite, puis vérifiez que le point de terminaison FHIR est opérationnel :
curl -u "root:$(awk '/BOX_ROOT_CLIENT_SECRET:/{print $2}' docker-compose.yaml)" http://localhost:8080/fhir/metadata
Vous devriez voir un CapabilityStatement JSON.
Envoyez le bundle.json que vous venez de créer :
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 retourne un lot transaction-response — une entrée par entrée d'entrée, chacune avec un 201 Created et un location pointant vers la ressource stockée :
{
"resourceType": "Bundle",
"type": "transaction-response",
"entry": [
{ "response": { "status": "201 Created", "location": "Patient/<id>/_history/1" } },
{ "response": { "status": "201 Created", "location": "Observation/<id>/_history/1" } },
...
]
}
Interrogez une observation et examinez son 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..."
Plus de urn:uuid — Aidbox a réécrit les substituts de manière atomique lors de la validation.
Pour aller plus loin
- Davantage d'API de profil. D'autres fabriques, accesseurs et formes de tranches/extensions sont exercés dans les tests d'exemple du codegen :
profile-us-core-patient.test.ts,profile-us-core-bp.test.ts,profile-us-core-bodyweight.test.ts. - Combiner des profils de plusieurs paquets.
APIBuilder.fromPackage()peut être enchaîné — US Core aux côtés de votre IG personnalisé, ou aux côtés d'une base régionale. Consultez les exempleson-the-flypour le KBV allemand et les profils de base norvégiens. - Corriger des paquets défectueux à la volée. Les IG réels sont livrés avec des défauts (canoniques avec coquilles, liaisons manquantes).
preprocessPackagevous permet de les corriger au chargement sans bifurquer le paquet — voiron-the-fly/ccda'sgenerate.tsqui répare un canonique CDA contenant une coquille.
Conclusion
Le générateur émet à la fois les types R4 de base et une couche légère de classes de profils par-dessus — pas de DSL à l'exécution, pas d'ORM, pas de cadriciel. toResource() vous donne toujours une ressource FHIR brute que vous pouvez envoyer à n'importe quel serveur.
@atomic-ehr/codegen est sous licence MIT ; les signalements de problèmes et les demandes de tirage sont les bienvenus.




