Construir recursos US Core a mano es tedioso. Se estampa meta.profile, se buscan códigos LOINC, se escribe a mano la extensión anidada us-core-race — cada campo es un error tipográfico esperando ocurrir, cada perfil tiene su propia versión de la misma ceremonia.
@atomic-ehr/codegen hace desaparecer ese código repetitivo. Apúntelo al US Core IG y obtendrá una clase TypeScript por perfil, con accesores tipados para valores fijos, extensiones y slices, más un validate() que conoce lo que el perfil requiere.
Este tutorial recorre ese flujo completo sobre dos perfiles US Core: US Core Patient y US Core Blood Pressure.
Qué Va a Construir
Un conversor de CSV a FHIR, construido paso a paso:
- generar clases de perfil para US Core Patient y US Core Blood Pressure a partir de
hl7.fhir.us.core@8.0.1, - convertir cada fila en un US Core Patient — setters de extensión tipados y
apply(), - convertir cada fila en un US Core Blood Pressure — slices tipados, LOINC fijo y
validate(), - empaquetarlos como un Bundle,
- leer el bundle de vuelta con getters tipados para calcular una TA media,
- enviar el bundle a un servidor Aidbox local.
Requisitos Previos
- Node.js 20+ (o Bun) — se importa
@atomic-ehr/codegencomo librería desde un script TypeScript y se ejecuta contsxobun. La salida generada es TypeScript puro sin dependencias npm en tiempo de ejecución. - TypeScript 5+
- Familiaridad básica con FHIR y US Core (basta con saber qué significan «perfil» y «slice»)
Paso 1 — Generar las Clases de Perfil
Configure un proyecto nuevo:
mkdir ts-us-core-tutorial && cd ts-us-core-tutorial
npm init -y
npm install --save-dev @atomic-ehr/codegen tsx typescript
Cree 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();
Dos aspectos importantes:
generateProfile: true— emite una clase envolvente por perfil con accesores tipados para extensiones, slices y valores fijos. Sin él, solo se generan los tipos R4 base.treeShake: { ... }— solo se generan los canonicals listados y sus dependencias transitivas (~50 archivos en lugar de 250+).
Ejecútelo. prettyReport(report) imprime un resumen agrupado para que vea lo que se emitió sin tener que explorar el directorio de salida:
$ 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
La estructura en disco tiene este aspecto:
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
El código completo del tutorial vive en Aidbox/examples — generate.ts, load.ts, avg.ts, el CSV y el directorio fhir-types/ ya confirmado para que pueda explorar el código generado sin ejecutar el generador. Para una exploración más amplia de la API de perfiles, el repositorio de codegen también tiene un ejemplo de prueba typescript-r4-us-core.
Paso 2 — Fila a US Core Patient
La entrada es patients.csv — datos demográficos básicos más una lectura de TA por paciente. La raza usa los códigos de categoría OMB que espera 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 analizador trivial entrega cada fila como cadenas de texto simples; todo el estrechamiento de tipos y el análisis numérico ocurre más tarde, en el momento en que se pasan los valores a los setters de perfil tipados.
parseCsv(path: string): Row[] en load.ts — código repetitivo, haga clic para expandir
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;
});
};
Esa es la mitad aburrida. La mitad interesante es convertir cada Row en un US Core Patient — el perfil añade un puñado de extensiones y eleva identifier y name a obligatorios. La clase generada tiene setters tipados para todos ellos:
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;
};
Dos fases:
- Construir el
Patientplano — los campos requeridos por el perfil (identifier,name) y los must-support (gender,birthDate) como un recurso R4 tipado. - Luego
USCorePatientProfile.apply(basePatient)estampameta.profiley devuelve una instancia de perfil con accesores tipados para las extensiones US Core.
Dos notas sobre lo que hace la API de perfil por usted:
- Tres formas de setter de extensión.
setRace({ ombCategory, text })acepta entrada plana y genera el mecanismo deextension[]anidado. También acepta una instancia de perfil tipada o una extensión FHIR raw para paso directo. - Sin setters para los campos base must-support.
gender,birthDateyaddressno son perfilados más allá por US Core, por lo que la clase de perfil no emite envoltorios estilo.setGender()— rellénelos como campos normales de Patient.validate()aún avisa si falta un campo must-support.
Paso 3 — Fila a US Core Blood Pressure
El perfil BP es donde el codegen realmente justifica su existencia. El perfil US Core Blood Pressure:
- fija
codea LOINC 85354-9 («Blood pressure panel»), - fija un slice de categoría
vital-signs, - define los slices
component[systolic]ycomponent[diastolic]con discriminadores LOINC específicos (8480-6 y 8462-4), - requiere un
effectiveDateTimeoeffectivePeriod, - requiere
valueQuantitydentro de cada slice.
Escribir todo eso a mano por fila es exactamente el tipo de tarea que el codegen elimina. La clase generada lo reduce a tres 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;
};
Lo que ocurre internamente:
create()hace la ceremonia. Estampameta.profile, rellena elcodefijo (LOINC 85354-9), añade el slice de categoría vital-signs y agrega stubs vacíos decomponent[systolic]/component[diastolic]con los códigos discriminadores ya establecidos.setSystolic({ value, unit, ... })rellena elvalueQuantitydentro del slice sistólico. El campocodediscriminador en ese componente ya está ahí desdecreate()— usted solo proporciona la lectura.validate()devuelve{ errors, warnings }. Los errores bloquean (campos requeridos, campos excluidos, variantes de elección no permitidas, cardinalidad de slice). Los avisos señalan preocupaciones must-support. Una fila mal formada falla rápido con el MRN — no lo descubre en el momento del POST.
Usted no escribió los códigos discriminadores. No tuvo que recordar 85354-9. Los únicos códigos en su fuente son los que el perfil no dicta — y para la TA, no hay ninguno.
Paso 4 — Ensamblar el Bundle
Cada fila produce un Patient y una Observation de TA vinculados por el marcador urn:uuid del Patient. Empaquételos como entradas de transacción:
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`);
Ejecute el cargador completo:
$ npx tsx load.ts
# Loaded 5 rows
# Wrote bundle with 10 entries
Vale la pena destacar:
Bundle<T>se propaga. ComoBundleyBundleEntryson genéricos sobre el recurso contenido (conResourcecomo valor por defecto),Bundle<Patient | Observation>estrechaentry[].resourcea esa unión. Esa es la mitad a nivel de tipos; en el Paso 5 añadiremos estrechamiento en tiempo de ejecución con conocimiento de perfil usandois().- Referencias mediante
urn:uuid. ElfullUrldel paciente es un UUID; elsubject.referencede la observación apunta al mismo UUID.Reference.referenceestá tipado como una unión que cubre todas las formas de referencia literal FHIR —Patient/${id}, absolutahttp://...,urn:uuid:...,urn:oid:...y#fragment— por lo que el marcador encaja sin necesidad de un cast. Al confirmar la transacción, el servidor resuelve ambos UUID a IDs de recurso reales de forma atómica.
Paso 5 — Lectura: TA Media del Bundle
Escribir es la mitad de la historia. Lea bundle.json de vuelta y calcule la TA media sistólica/diastólica para ejercitar la API de lectura en 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)
Tres cosas que hace el perfil aquí:
is()es un type guard. CompruebaresourceTypeymeta.profile.includes(canonicalUrl); úselo como predicado de.filter()en cualquier colección. No construye nada, no valida nada.from(obs)valida los supervivientes. Una vez queis()ha estrechado la entrada,from()ejecuta la comprobación estructural (campos requeridos, cardinalidad de slice) y lanza una excepción si un recurso que declara el perfil está mal formado — de modo que un bundle roto falla en el momento de la lectura, no en el siguiente acceso al campo.getSystolic()/getDiastolic()devuelven slices planos. Sin necesidad de recorrercomponent[].code.coding[].codepara hacer coincidir los códigos LOINC. El perfil ya sabe qué slice es cuál.
Ese es el ciclo completo: CSV → perfiles tipados → Bundle validado → lectura de vuelta tipada con getters con conocimiento de perfil. Las mismas pocas líneas procesarían TAs obtenidas de un servidor FHIR, cargadas desde un archivo o recibidas en una Subscription — el perfil tipado es la forma común, independientemente de la fuente.
Paso 6 — Enviar el Bundle a un Servidor FHIR
El pipeline tipado es solo la mitad de la historia. Para ver realmente la confirmación de la transacción — IDs de paciente asignados, referencias urn:uuid reescritas, recursos almacenados y buscables — necesita un servidor FHIR. Ponga en marcha y ejecute Aidbox:
curl -JO https://aidbox.app/runme && docker compose up
Abra http://localhost:8080 en su navegador para obtener una licencia gratuita de desarrollador y, a continuación, verifique que el endpoint FHIR está activo:
curl -u "root:$(awk '/BOX_ROOT_CLIENT_SECRET:/{print $2}' docker-compose.yaml)" http://localhost:8080/fhir/metadata
Debería ver un CapabilityStatement JSON.
Envíe el bundle.json que acaba de crear:
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 devuelve un bundle transaction-response — una entrada por entrada de entrada, cada una con 201 Created y una location que apunta al recurso almacenado:
{
"resourceType": "Bundle",
"type": "transaction-response",
"entry": [
{ "response": { "status": "201 Created", "location": "Patient/<id>/_history/1" } },
{ "response": { "status": "201 Created", "location": "Observation/<id>/_history/1" } },
...
]
}
Consulte una observación de vuelta y observe su 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..."
Sin urn:uuid — Aidbox reescribió los marcadores de forma atómica al confirmar.
Qué Hacer a Continuación
- Más de la API de perfiles. Otras fábricas, getters y formas de slice/extensión se ejercitan en los tests de ejemplo del codegen:
profile-us-core-patient.test.ts,profile-us-core-bp.test.ts,profile-us-core-bodyweight.test.ts. - Mezcle perfiles de múltiples paquetes.
APIBuilder.fromPackage()puede encadenarse — US Core junto con su IG personalizado, o junto con una base regional. Consulte los ejemploson-the-flypara KBV alemán y perfiles base noruegos. - Parchee paquetes defectuosos al vuelo. Los IGs del mundo real se distribuyen con defectos (canonicals con errores tipográficos, bindings faltantes).
preprocessPackagele permite corregirlos en el momento de la carga sin bifurcar el paquete — veaon-the-fly/ccda'sgenerate.tsreparando un canonical CDA con error tipográfico.
Conclusión
El generador emite tanto los tipos R4 base como una fina capa de clases de perfil encima — sin DSL en tiempo de ejecución, sin ORM, sin framework. toResource() siempre le da un recurso FHIR plano que puede enviar a cualquier servidor.
@atomic-ehr/codegen tiene licencia MIT; los issues y los PRs son bienvenidos.





