Skip to content

Feature: safe helpers for coded fields — Literal unions and ValueSet-bound Codings #142

@ryukzak

Description

@ryukzak

Problem

Generated FHIR types narrow coded fields at compile time (Literal unions, CodeableConcept<T>), but they stop short of validating or enriching values at runtime. When data flows in from an external source (CSV, HTTP form, another system's JSON), users end up either:

  1. as-casting — compile-time only, no runtime safety.
  2. Re-supplying information the binding already knows — e.g., carrying system and display through the pipeline even though they're derivable from the ValueSet binding.

Two concrete pain points surfaced in the tutorial US Core Profiles in TypeScript with @atomic-ehr/codegen. Example code: HealthSamurai/examples — atomic-ehr-codegen-typescript-us-core-profiles.

Pain point 1: as cast on narrow Literal unions

const basePatient: Patient = {
  resourceType: "Patient",
  identifier: [...],
  name: [...],
  gender: row.gender as Patient["gender"],   // ❌ "FEMALE" / "Female" / "mal" / anything — slips through
  birthDate: row.birthDate,
};

Patient["gender"] is "male" | "female" | "other" | "unknown" | undefined. The as cast says "trust me" with no runtime check. A typo in the CSV silently produces an invalid Patient that fails at POST time or, worse, serializes with garbage.

Pain point 2: Manual system / display on ValueSet-bound Codings

patient.setRace({
  ombCategory: {
    system: "urn:oid:2.16.840.1.113883.6.238",
    code: row.raceCode,
    display: row.raceDisplay,
  },
  text: row.raceDisplay,
});

The ombCategory field in the US Core Race extension is bound to the OMB Race Categories ValueSet. The generator already knows:

  • 2106-3 → system urn:oid:2.16.840.1.113883.6.238, display "White"
  • 2054-5 → system urn:oid:2.16.840.1.113883.6.238, display "Black or African American"
  • ...

Yet the caller has to carry system, display, and code through every hop (CSV columns, DB rows, intermediate JSON). That's:

  • Duplicated information — the binding knows it; the call site shouldn't re-state it.
  • A drift vector — subtle display mismatches ("White" vs "white" vs "White or European") slip in and nobody notices until someone searches or audits.
  • No validationrow.raceCode = "XYZ-99" passes tsc happily and posts to the server, where it may or may not be caught.

Proposal

Emit helpers that know the ValueSet binding at codegen time.

For Literal unions (pain point 1)

Per-binding parse* helpers that throw on unknown values:

// Generated alongside Patient.ts
export const parsePatientGender = (input: string): Patient["gender"] => {
  const valid = ["male", "female", "other", "unknown"] as const;
  if ((valid as readonly string[]).includes(input)) return input as Patient["gender"];
  throw new Error(`Invalid Patient.gender: ${JSON.stringify(input)}. Expected one of: ${valid.join(", ")}`);
};

Or a generic helper in profile-helpers.ts:

import { parseLiteral } from "./fhir-types/profile-helpers";
gender: parseLiteral(row.gender, ["male", "female", "other", "unknown"] as const, "Patient.gender")

For ValueSet-bound Codings (pain point 2)

Code-to-Coding helpers that fill system and display from the bound ValueSet:

// Generated alongside Extension_USCoreRaceExtension.ts
export const USCoreOmbRaceCategories = {
  "2106-3": { system: "urn:oid:2.16.840.1.113883.6.238", code: "2106-3", display: "White" },
  "2054-5": { system: "urn:oid:2.16.840.1.113883.6.238", code: "2054-5", display: "Black or African American" },
  // ...
} as const;

export const parseOmbRaceCategory = (input: string): Coding => {
  if (input in USCoreOmbRaceCategories) {
    return USCoreOmbRaceCategories[input as keyof typeof USCoreOmbRaceCategories];
  }
  throw new Error(`Invalid OMB race category code: ${JSON.stringify(input)}`);
};

Usage (the row.raceSystem / row.raceDisplay CSV columns become unnecessary):

patient.setRace({ ombCategory: parseOmbRaceCategory(row.raceCode) });

Or, if setters accept a code-only shorthand directly, even simpler:

patient.setRace({ ombCategoryCode: row.raceCode });   // system/display looked up at call time

(Trade-off for the shorthand form: loses the ability to override display if the caller wants a local-language label.)

Bonus: primitive parsers

Same pattern applies to numeric primitives and booleans — a tiny set of parseNumber / parseBoolean / parseInstant helpers in profile-helpers.ts would cover most CSV ingestion cases.

Scope and trade-offs

  • Helpers make sense for bindings with strength = required | extensible. preferred / example bindings could get the helper with a warning, or skip.
  • Large ValueSets (thousands of codes, SNOMED, LOINC) probably shouldn't emit the full lookup table — either validation-only (isInValueSet(code)), or skip and fall back to plain Coding input.
  • CodeableConcept<T> already narrows the type parameter for small bound ValueSets; this proposal adds the runtime validation + enrichment layer on top.

Context

Both pain points surfaced in the tutorial US Core Profiles in TypeScript with @atomic-ehr/codegen. CSV-to-Bundle ingestion is one of the most common FHIR real-world patterns and the current story is "write manual validators or as-cast and pray". A ValueSet-aware helper layer would make the generated types meaningfully safer without forcing callers to restate what the generator already knows.

Related: #141 (Reference type narrowness) — same theme of safe boundaries between external string data and typed FHIR values.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions