|
1 | 1 | /** |
2 | | - * Runtime helpers for generated FHIR profile classes. |
| 2 | + * Runtime helpers for generated FHIR types and profile classes. |
3 | 3 | * |
4 | 4 | * This file is copied verbatim into every generated TypeScript output and |
5 | | - * imported by profile modules. It provides: |
| 5 | + * imported by profile and binding modules. It provides: |
6 | 6 | * |
7 | 7 | * - **Slice helpers** – match, get, set, and default-fill array slices |
8 | 8 | * defined by a FHIR StructureDefinition. |
|
12 | 12 | * profile classes can expose a flat API. |
13 | 13 | * - **Validation helpers** – lightweight structural checks that profile |
14 | 14 | * classes call from their `validate()` method. |
| 15 | + * - **Parse helpers** – validate / enrich values coming from external |
| 16 | + * sources (CSV, HTTP form, untyped JSON) into typed FHIR values. |
15 | 17 | * - **Misc utilities** – deep-match, deep-merge, path navigation. |
16 | 18 | */ |
17 | 19 |
|
@@ -447,3 +449,92 @@ export const validateReference = (res: object, profileName: string, field: strin |
447 | 449 | ? [] |
448 | 450 | : [`${profileName}: field '${field}' references '${refType}' but only ${allowed.join(", ")} are allowed`]; |
449 | 451 | }; |
| 452 | + |
| 453 | +// --------------------------------------------------------------------------- |
| 454 | +// Parse helpers |
| 455 | +// |
| 456 | +// Each `parse*` validates an untyped value (typically string from CSV or HTTP |
| 457 | +// form input) and returns a value of the typed FHIR shape. All throw an |
| 458 | +// `Error` on failure; the message includes `fieldName` when provided so the |
| 459 | +// call site can be located in stack traces. |
| 460 | +// --------------------------------------------------------------------------- |
| 461 | + |
| 462 | +/** |
| 463 | + * Validate that `input` is one of the literal values in `allowed`, returning |
| 464 | + * it as the narrowed type. Throws when `input` is not in the set. |
| 465 | + * |
| 466 | + * @example |
| 467 | + * parseLiteral(row.gender, ["male", "female", "other", "unknown"], "Patient.gender") |
| 468 | + */ |
| 469 | +export const parseLiteral = <const T extends string>(input: unknown, allowed: readonly T[], fieldName?: string): T => { |
| 470 | + if (typeof input === "string" && (allowed as readonly string[]).includes(input)) return input as T; |
| 471 | + const where = fieldName ? `${fieldName}: ` : ""; |
| 472 | + throw new Error(`${where}invalid value ${JSON.stringify(input)}. Expected one of: ${allowed.join(", ")}`); |
| 473 | +}; |
| 474 | + |
| 475 | +/** |
| 476 | + * Look up `input` in a code → `{system, code, display}` table and return the |
| 477 | + * corresponding FHIR Coding. Throws when the code is not in the table. |
| 478 | + * |
| 479 | + * Generated per-binding helpers wrap this with the binding's lookup table so |
| 480 | + * callers only need to supply the code string. |
| 481 | + * |
| 482 | + * @example |
| 483 | + * parseCoding(row.raceCode, USCoreOmbRaceCategoriesCodes, "Race.ombCategory") |
| 484 | + */ |
| 485 | +export const parseCoding = <T extends string>( |
| 486 | + input: unknown, |
| 487 | + lookup: Readonly<Record<string, { system?: string; code: T; display?: string }>>, |
| 488 | + fieldName?: string, |
| 489 | +): { system?: string; code: T; display?: string } => { |
| 490 | + if (typeof input === "string" && Object.hasOwn(lookup, input)) { |
| 491 | + const concept = lookup[input]; |
| 492 | + if (concept) return concept; |
| 493 | + } |
| 494 | + const where = fieldName ? `${fieldName}: ` : ""; |
| 495 | + const allowed = Object.keys(lookup).join(", "); |
| 496 | + throw new Error(`${where}invalid code ${JSON.stringify(input)}. Expected one of: ${allowed}`); |
| 497 | +}; |
| 498 | + |
| 499 | +/** |
| 500 | + * Coerce `input` into a boolean. Accepts `true`/`false`, `"true"`/`"false"`, |
| 501 | + * `"1"`/`"0"` (case-insensitive). Throws on anything else. |
| 502 | + */ |
| 503 | +export const parseBoolean = (input: unknown, fieldName?: string): boolean => { |
| 504 | + if (typeof input === "boolean") return input; |
| 505 | + if (typeof input === "string") { |
| 506 | + const v = input.trim().toLowerCase(); |
| 507 | + if (v === "true" || v === "1") return true; |
| 508 | + if (v === "false" || v === "0") return false; |
| 509 | + } |
| 510 | + const where = fieldName ? `${fieldName}: ` : ""; |
| 511 | + throw new Error(`${where}invalid boolean ${JSON.stringify(input)}`); |
| 512 | +}; |
| 513 | + |
| 514 | +/** |
| 515 | + * Coerce `input` into a finite number. Accepts numbers and numeric strings. |
| 516 | + * Throws on `NaN`, `Infinity`, or non-numeric input. |
| 517 | + */ |
| 518 | +export const parseNumber = (input: unknown, fieldName?: string): number => { |
| 519 | + if (typeof input === "number" && Number.isFinite(input)) return input; |
| 520 | + if (typeof input === "string" && input.trim() !== "") { |
| 521 | + const n = Number(input); |
| 522 | + if (Number.isFinite(n)) return n; |
| 523 | + } |
| 524 | + const where = fieldName ? `${fieldName}: ` : ""; |
| 525 | + throw new Error(`${where}invalid number ${JSON.stringify(input)}`); |
| 526 | +}; |
| 527 | + |
| 528 | +/** |
| 529 | + * Validate that `input` is a FHIR `instant` string (ISO 8601 with timezone). |
| 530 | + * Returns the original string on success; throws on malformed input. |
| 531 | + */ |
| 532 | +export const parseInstant = (input: unknown, fieldName?: string): string => { |
| 533 | + if (typeof input === "string") { |
| 534 | + // FHIR instant: YYYY-MM-DDTHH:MM:SS(.sss)?(Z|[+-]HH:MM) |
| 535 | + const re = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; |
| 536 | + if (re.test(input) && !Number.isNaN(Date.parse(input))) return input; |
| 537 | + } |
| 538 | + const where = fieldName ? `${fieldName}: ` : ""; |
| 539 | + throw new Error(`${where}invalid instant ${JSON.stringify(input)}`); |
| 540 | +}; |
0 commit comments