|
8 min de lectura
|

@atomic-ehr/codegen: Perfiles US Core en TypeScript

Resumir este artículo con:
ChatGPTPerplexityClaudeGrok

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:

  1. generar clases de perfil para US Core Patient y US Core Blood Pressure a partir de hl7.fhir.us.core@8.0.1,
  2. convertir cada fila en un US Core Patient — setters de extensión tipados y apply(),
  3. convertir cada fila en un US Core Blood Pressure — slices tipados, LOINC fijo y validate(),
  4. empaquetarlos como un Bundle,
  5. leer el bundle de vuelta con getters tipados para calcular una TA media,
  6. enviar el bundle a un servidor Aidbox local.

Requisitos Previos

  • Node.js 20+ (o Bun) — se importa @atomic-ehr/codegen como librería desde un script TypeScript y se ejecuta con tsx o bun. 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:

  1. Construir el Patient plano — los campos requeridos por el perfil (identifier, name) y los must-support (gender, birthDate) como un recurso R4 tipado.
  2. Luego USCorePatientProfile.apply(basePatient) estampa meta.profile y 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 de extension[] 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, birthDate y address no 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 code a LOINC 85354-9 («Blood pressure panel»),
  • fija un slice de categoría vital-signs,
  • define los slices component[systolic] y component[diastolic] con discriminadores LOINC específicos (8480-6 y 8462-4),
  • requiere un effectiveDateTime o effectivePeriod,
  • requiere valueQuantity dentro 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. Estampa meta.profile, rellena el code fijo (LOINC 85354-9), añade el slice de categoría vital-signs y agrega stubs vacíos de component[systolic] / component[diastolic] con los códigos discriminadores ya establecidos.
  • setSystolic({ value, unit, ... }) rellena el valueQuantity dentro del slice sistólico. El campo code discriminador en ese componente ya está ahí desde create() — 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. Como Bundle y BundleEntry son genéricos sobre el recurso contenido (con Resource como valor por defecto), Bundle<Patient | Observation> estrecha entry[].resource a 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 usando is().
  • Referencias mediante urn:uuid. El fullUrl del paciente es un UUID; el subject.reference de la observación apunta al mismo UUID. Reference.reference está tipado como una unión que cubre todas las formas de referencia literal FHIR — Patient/${id}, absoluta http://..., 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. Comprueba resourceType y meta.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 que is() 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 recorrer component[].code.coding[].code para 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

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.

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

Compartir este artículo
Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

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