Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 93 additions & 2 deletions assets/api/writer-generator/typescript/profile-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Runtime helpers for generated FHIR profile classes.
* Runtime helpers for generated FHIR types and profile classes.
*
* This file is copied verbatim into every generated TypeScript output and
* imported by profile modules. It provides:
* imported by profile and binding modules. It provides:
*
* - **Slice helpers** – match, get, set, and default-fill array slices
* defined by a FHIR StructureDefinition.
Expand All @@ -12,6 +12,8 @@
* profile classes can expose a flat API.
* - **Validation helpers** – lightweight structural checks that profile
* classes call from their `validate()` method.
* - **Parse helpers** – validate / enrich values coming from external
* sources (CSV, HTTP form, untyped JSON) into typed FHIR values.
* - **Misc utilities** – deep-match, deep-merge, path navigation.
*/

Expand Down Expand Up @@ -447,3 +449,92 @@ export const validateReference = (res: object, profileName: string, field: strin
? []
: [`${profileName}: field '${field}' references '${refType}' but only ${allowed.join(", ")} are allowed`];
};

// ---------------------------------------------------------------------------
// Parse helpers
//
// Each `parse*` validates an untyped value (typically string from CSV or HTTP
// form input) and returns a value of the typed FHIR shape. All throw an
// `Error` on failure; the message includes `fieldName` when provided so the
// call site can be located in stack traces.
// ---------------------------------------------------------------------------

/**
* Validate that `input` is one of the literal values in `allowed`, returning
* it as the narrowed type. Throws when `input` is not in the set.
*
* @example
* parseLiteral(row.gender, ["male", "female", "other", "unknown"], "Patient.gender")
*/
export const parseLiteral = <const T extends string>(input: unknown, allowed: readonly T[], fieldName?: string): T => {
if (typeof input === "string" && (allowed as readonly string[]).includes(input)) return input as T;
const where = fieldName ? `${fieldName}: ` : "";
throw new Error(`${where}invalid value ${JSON.stringify(input)}. Expected one of: ${allowed.join(", ")}`);
};

/**
* Look up `input` in a code → `{system, code, display}` table and return the
* corresponding FHIR Coding. Throws when the code is not in the table.
*
* Generated per-binding helpers wrap this with the binding's lookup table so
* callers only need to supply the code string.
*
* @example
* parseCoding(row.raceCode, USCoreOmbRaceCategoriesCodes, "Race.ombCategory")
*/
export const parseCoding = <T extends string>(
input: unknown,
lookup: Readonly<Record<string, { system?: string; code: T; display?: string }>>,
fieldName?: string,
): { system?: string; code: T; display?: string } => {
if (typeof input === "string" && Object.hasOwn(lookup, input)) {
const concept = lookup[input];
if (concept) return concept;
}
const where = fieldName ? `${fieldName}: ` : "";
const allowed = Object.keys(lookup).join(", ");
throw new Error(`${where}invalid code ${JSON.stringify(input)}. Expected one of: ${allowed}`);
};

/**
* Coerce `input` into a boolean. Accepts `true`/`false`, `"true"`/`"false"`,
* `"1"`/`"0"` (case-insensitive). Throws on anything else.
*/
export const parseBoolean = (input: unknown, fieldName?: string): boolean => {
if (typeof input === "boolean") return input;
if (typeof input === "string") {
const v = input.trim().toLowerCase();
if (v === "true" || v === "1") return true;
if (v === "false" || v === "0") return false;
}
const where = fieldName ? `${fieldName}: ` : "";
throw new Error(`${where}invalid boolean ${JSON.stringify(input)}`);
};

/**
* Coerce `input` into a finite number. Accepts numbers and numeric strings.
* Throws on `NaN`, `Infinity`, or non-numeric input.
*/
export const parseNumber = (input: unknown, fieldName?: string): number => {
if (typeof input === "number" && Number.isFinite(input)) return input;
if (typeof input === "string" && input.trim() !== "") {
const n = Number(input);
if (Number.isFinite(n)) return n;
}
const where = fieldName ? `${fieldName}: ` : "";
throw new Error(`${where}invalid number ${JSON.stringify(input)}`);
};

/**
* Validate that `input` is a FHIR `instant` string (ISO 8601 with timezone).
* Returns the original string on success; throws on malformed input.
*/
export const parseInstant = (input: unknown, fieldName?: string): string => {
if (typeof input === "string") {
// FHIR instant: YYYY-MM-DDTHH:MM:SS(.sss)?(Z|[+-]HH:MM)
const re = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
if (re.test(input) && !Number.isNaN(Date.parse(input))) return input;
}
const where = fieldName ? `${fieldName}: ` : "";
throw new Error(`${where}invalid instant ${JSON.stringify(input)}`);
};
68 changes: 68 additions & 0 deletions examples/typescript-r4/bindings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Demo: parse helpers for coded fields.
*
* Generated `bindings.ts` exports a `parse<Binding>` for each ValueSet-bound
* field. These parsers replace the unsafe `as Patient["gender"]` cast by
* validating the input string at runtime and throwing on unknown values.
* `parse<Binding>Coding` additionally fills in `system` and `display` from the
* binding's lookup table, so callers can supply just the code.
*/

import { describe, expect, test } from "bun:test";
import {
AdministrativeGenderCodes,
AdministrativeGenderConcepts,
parseAdministrativeGender,
parseAdministrativeGenderCoding,
} from "./fhir-types/hl7-fhir-r4-core/bindings";
import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient";

describe("demo: typed Patient.gender via parseAdministrativeGender", () => {
test("CSV-like row goes through the parser, not an `as` cast", () => {
const row = { gender: "female", birthDate: "1990-04-15" };

const patient: Patient = {
resourceType: "Patient",
gender: parseAdministrativeGender(row.gender, "Patient.gender"),
birthDate: row.birthDate,
};

expect(patient.gender).toBe("female");
});

test("parser throws on values outside the binding", () => {
expect(() => parseAdministrativeGender("FEMALE", "Patient.gender")).toThrow(
/Patient.gender: invalid value "FEMALE"/,
);
});

test("Codes tuple is the literal union source-of-truth", () => {
expect(AdministrativeGenderCodes).toEqual(["male", "female", "other", "unknown"]);
});
});

describe("demo: ValueSet-bound Coding via parseAdministrativeGenderCoding", () => {
test("supplying only the code fills in system and display", () => {
const coding = parseAdministrativeGenderCoding("male");

expect(coding).toEqual({
system: "http://hl7.org/fhir/administrative-gender",
code: "male",
display: "Male",
});
});

test("lookup table is exposed for direct access", () => {
expect(AdministrativeGenderConcepts.unknown).toEqual({
system: "http://hl7.org/fhir/administrative-gender",
code: "unknown",
display: "Unknown",
});
});

test("parser throws on values outside the binding", () => {
expect(() => parseAdministrativeGenderCoding("XX", "Patient.coding")).toThrow(
/Patient.coding: invalid code "XX"/,
);
});
});
Loading
Loading