From a8d9f65d0f0cb6bc58e5b9825ae655daaf4ff66b Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 6 Mar 2026 13:31:54 +0100 Subject: [PATCH 1/6] Refactor profile classes to use shared profile-helpers.ts runtime asset Extract profile-helpers.ts into a static asset copied into generated output. Refactor validate helpers, extract slice match objects to static constants, replace inline slice logic with generic helpers, resolve profile references to base resource types, fix CCDA generation issues. --- .../typescript/profile-helpers.ts | 376 +++++++++++ biome.json | 1 + .../writer-generator/typescript/profile.ts | 589 +++++------------- src/api/writer-generator/typescript/utils.ts | 6 +- src/api/writer-generator/typescript/writer.ts | 18 +- 5 files changed, 551 insertions(+), 439 deletions(-) create mode 100644 assets/api/writer-generator/typescript/profile-helpers.ts diff --git a/assets/api/writer-generator/typescript/profile-helpers.ts b/assets/api/writer-generator/typescript/profile-helpers.ts new file mode 100644 index 000000000..4f45db45b --- /dev/null +++ b/assets/api/writer-generator/typescript/profile-helpers.ts @@ -0,0 +1,376 @@ +/** + * Runtime helpers for generated FHIR profile classes. + * + * This file is copied verbatim into every generated TypeScript output and + * imported by profile modules. It provides: + * + * - **Slice helpers** – match, get, set, and default-fill array slices + * defined by a FHIR StructureDefinition. + * - **Extension helpers** – read complex (nested) FHIR extensions into + * plain objects. + * - **Choice-type helpers** – wrap/unwrap polymorphic `value[x]` fields so + * profile classes can expose a flat API. + * - **Validation helpers** – lightweight structural checks that profile + * classes call from their `validate()` method. + * - **Misc utilities** – deep-match, deep-merge, path navigation. + */ + +// --------------------------------------------------------------------------- +// General utilities +// --------------------------------------------------------------------------- + +/** Type guard: `value` is a non-null, non-array plain object. */ +export const isRecord = (value: unknown): value is Record => { + return value !== null && typeof value === "object" && !Array.isArray(value); +}; + +/** + * Walk `path` segments from `root`, creating intermediate objects (or using + * the first element of an existing array) as needed. Returns the leaf object. + * + * Used by extension setters to reach a nested target inside a resource. + * + * @example + * ensurePath(resource, ["contact", "telecom"]) + * // → resource.contact.telecom (created if absent) + */ +export const ensurePath = (root: Record, path: string[]): Record => { + let current: Record = root; + for (const segment of path) { + if (Array.isArray(current[segment])) { + const list = current[segment] as unknown[]; + if (list.length === 0) { + list.push({}); + } + current = list[0] as Record; + } else { + if (!isRecord(current[segment])) { + current[segment] = {}; + } + current = current[segment] as Record; + } + } + return current; +}; + +// --------------------------------------------------------------------------- +// Deep match / merge +// --------------------------------------------------------------------------- + +/** + * Deep-merge `match` into `target`, mutating `target` in place. + * Skips prototype-pollution keys. Used internally by {@link applySliceMatch}. + */ +export const mergeMatch = (target: Record, match: Record): void => { + for (const [key, matchValue] of Object.entries(match)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue; + } + if (isRecord(matchValue)) { + if (isRecord(target[key])) { + mergeMatch(target[key] as Record, matchValue); + } else { + target[key] = { ...matchValue }; + } + } else { + target[key] = matchValue; + } + } +}; + +/** + * Shallow-clone `input` then deep-merge the slice discriminator values from + * `match` on top, returning a complete slice element ready for insertion. + * + * @example + * applySliceMatch({ text: "hi" }, { coding: { code: "vital-signs", system: "…" } }) + * // → { text: "hi", coding: { code: "vital-signs", system: "…" } } + */ +export const applySliceMatch = (input: Partial, match: Partial>): T => { + const result = { ...input } as Record; + mergeMatch(result, match); + return result as T; +}; + +/** + * Recursively test whether `value` structurally contains everything in + * `match`. Arrays are matched with "every match item has a corresponding + * value item" semantics; objects are matched key-by-key; primitives use `===`. + * + * This is the core discriminator check used to identify which array element + * belongs to a given FHIR slice. + */ +export const matchesValue = (value: unknown, match: unknown): boolean => { + if (Array.isArray(match)) { + if (!Array.isArray(value)) { + return false; + } + return match.every((matchItem) => value.some((item) => matchesValue(item, matchItem))); + } + if (isRecord(match)) { + if (!isRecord(value)) { + return false; + } + for (const [key, matchValue] of Object.entries(match)) { + if (!matchesValue((value as Record)[key], matchValue)) { + return false; + } + } + return true; + } + return value === match; +}; + +// --------------------------------------------------------------------------- +// Extension helpers +// --------------------------------------------------------------------------- + +/** + * Read a complex (nested) FHIR extension into a plain key/value object. + * + * Each entry in `config` describes one sub-extension by URL, the name of its + * value field (e.g. `"valueString"`), and whether it may repeat. + * + * @returns A record keyed by sub-extension URL, or `undefined` if the + * extension has no nested children. + */ +export const extractComplexExtension = ( + extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, + config: Array<{ name: string; valueField: string; isArray: boolean }>, +): Record | undefined => { + if (!extension?.extension) return undefined; + const result: Record = {}; + for (const { name, valueField, isArray } of config) { + const subExts = extension.extension.filter((e) => e.url === name); + if (isArray) { + result[name] = subExts.map((e) => (e as Record)[valueField]); + } else if (subExts[0]) { + result[name] = (subExts[0] as Record)[valueField]; + } + } + return result; +}; + +// --------------------------------------------------------------------------- +// Slice helpers +// --------------------------------------------------------------------------- + +/** + * Remove discriminator keys from a slice element, returning only the + * user-supplied portion. Used by slice getters so callers see a clean object + * without the fixed discriminator values baked in. + */ +export const stripMatchKeys = (slice: object, matchKeys: string[]): T => { + const result = { ...slice } as Record; + for (const key of matchKeys) { + delete result[key]; + } + return result as T; +}; + +/** + * Wrap a flat input object under a choice-type key before inserting into a + * slice. For example, a Quantity value destined for `valueQuantity` is + * wrapped as `{ valueQuantity: { ...input } }`. + * + * No-op when `input` is empty (the slice will contain only discriminator + * defaults). + */ +export const wrapSliceChoice = (input: object, choiceVariant: string): Partial => { + if (Object.keys(input).length === 0) return input as Partial; + return { [choiceVariant]: input } as Partial; +}; + +/** + * Inverse of {@link wrapSliceChoice}: strip discriminator keys, then hoist + * the value inside `choiceVariant` up to the top level. + * + * @example + * unwrapSliceChoice(raw, ["code"], "valueQuantity") + * // removes "code", moves raw.valueQuantity.* to top level + */ +export const unwrapSliceChoice = (slice: object, matchKeys: string[], choiceVariant: string): T => { + const result = { ...slice } as Record; + for (const key of matchKeys) { + delete result[key]; + } + const variantValue = result[choiceVariant]; + delete result[choiceVariant]; + if (isRecord(variantValue)) { + Object.assign(result, variantValue); + } + return result as T; +}; + +/** + * Ensure that every required slice has at least a stub element in the array. + * Each `match` is a discriminator pattern; if no existing item satisfies it, + * a deep clone of the pattern is appended. + * + * Called in `createResource` so that required slices are always present even + * when the caller omits them. + */ +export const ensureSliceDefaults = (items: T[], ...matches: Record[]): T[] => { + for (const match of matches) { + if (!items.some((item) => matchesValue(item, match))) { + items.push(structuredClone(match) as T); + } + } + return items; +}; + +/** + * Add `canonicalUrl` to `resource.meta.profile` if not already present. + * Creates `meta` and `profile` when missing. + */ +export const ensureProfile = (resource: { meta?: { profile?: string[] } }, canonicalUrl: string): void => { + const meta = (resource.meta ??= {}); + const profiles = (meta.profile ??= []); + if (!profiles.includes(canonicalUrl)) profiles.push(canonicalUrl); +}; + +/** + * Find or insert a slice element in `list`. If an element matching `match` + * already exists it is replaced in place; otherwise `value` is appended. + */ +export const setArraySlice = (list: T[], match: Record, value: T): void => { + const index = list.findIndex((item) => matchesValue(item, match)); + if (index === -1) { + list.push(value); + } else { + list[index] = value; + } +}; + +/** Return the first element in `list` that satisfies the slice discriminator `match`. */ +export const getArraySlice = (list: readonly T[] | undefined, match: Record): T | undefined => { + if (!list) return undefined; + return list.find((item) => matchesValue(item, match)); +}; + +// --------------------------------------------------------------------------- +// Validation helpers +// +// Each function returns an array of human-readable error strings (empty = ok). +// Profile classes spread them all into a single array from `validate()`. +// --------------------------------------------------------------------------- + +/** Checks that `field` is present (not `undefined` or `null`). */ +export const validateRequired = (res: Record, profileName: string, field: string): string[] => { + return res[field] === undefined || res[field] === null + ? [`${profileName}: required field '${field}' is missing`] + : []; +}; + +/** Checks that `field` is absent (profiles may exclude base fields). */ +export const validateExcluded = (res: Record, profileName: string, field: string): string[] => { + return res[field] !== undefined ? [`${profileName}: field '${field}' must not be present`] : []; +}; + +/** Checks that `field` structurally contains the expected fixed value. */ +export const validateFixedValue = ( + res: Record, + profileName: string, + field: string, + expected: unknown, +): string[] => { + return matchesValue(res[field], expected) + ? [] + : [`${profileName}: field '${field}' does not match expected fixed value`]; +}; + +/** + * Checks that the number of array elements matching `match` (a slice + * discriminator) falls within [`min`, `max`]. Pass `max = 0` for unbounded. + */ +export const validateSliceCardinality = ( + res: Record, + profileName: string, + field: string, + match: Record, + sliceName: string, + min: number, + max: number, +): string[] => { + const items = res[field] as unknown[] | undefined; + const count = (items ?? []).filter((item) => matchesValue(item, match)).length; + const errors: string[] = []; + if (count < min) { + errors.push(`${profileName}.${field}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`); + } + if (max > 0 && count > max) { + errors.push(`${profileName}.${field}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`); + } + return errors; +}; + +/** + * Checks that at least one of the listed choice-type variants is present. + * E.g. `["effectiveDateTime", "effectivePeriod"]`. + */ +export const validateChoiceRequired = ( + res: Record, + profileName: string, + choices: string[], +): string[] => { + return choices.some((c) => res[c] !== undefined) + ? [] + : [`${profileName}: at least one of ${choices.join(", ")} is required`]; +}; + +/** + * Checks that the value of `field` has a code within `allowed`. + * Handles plain strings, Coding objects, and CodeableConcept objects. + * Skips validation when the field is absent. + */ +export const validateEnum = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; + if (typeof value === "string") { + return allowed.includes(value) + ? [] + : [`${profileName}: field '${field}' value '${value}' is not in allowed values`]; + } + const rec = value as Record; + // Coding + if (typeof rec.code === "string" && rec.system !== undefined) { + return allowed.includes(rec.code) + ? [] + : [`${profileName}: field '${field}' code '${rec.code}' is not in allowed values`]; + } + // CodeableConcept + if (Array.isArray(rec.coding)) { + const codes = (rec.coding as Record[]).map((c) => c.code as string).filter(Boolean); + const hasValid = codes.some((c) => allowed.includes(c)); + return hasValid ? [] : [`${profileName}: field '${field}' has no coding with an allowed code`]; + } + return []; +}; + +/** + * Checks that a Reference field points to one of the `allowed` resource + * types. Extracts the type from the `reference` string (the part before + * the first `/`). Skips validation when the field or reference is absent. + */ +export const validateReference = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; + const ref = (value as Record).reference as string | undefined; + if (!ref) return []; + const slashIdx = ref.indexOf("/"); + if (slashIdx === -1) return []; + const refType = ref.slice(0, slashIdx); + return allowed.includes(refType) + ? [] + : [`${profileName}: field '${field}' references '${refType}' but only ${allowed.join(", ")} are allowed`]; +}; diff --git a/biome.json b/biome.json index 8fd660110..99e2e879f 100644 --- a/biome.json +++ b/biome.json @@ -17,6 +17,7 @@ "package.json", "examples/*/*.ts", "examples/*.ts", + "assets/api/writer-generator/**/*.ts", ".zed/*.json" ] }, diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index d41ce336e..24d6ad9ad 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -38,10 +38,12 @@ import { import { resolveFieldTsType, resolvePrimitiveType, tsEnumType, tsGet, tsTypeFromIdentifier } from "./utils"; import type { TypeScript } from "./writer"; +const tsSliceStaticName = (name: string): string => name.replace(/\[x\]/g, "").replace(/[^a-zA-Z0-9_$]/g, "_"); + type ProfileFactoryInfo = { autoFields: { name: string; value: string }[]; /** Array fields with required slices — optional param with auto-merge of required stubs */ - sliceAutoFields: { name: string; tsType: string; typeId: Identifier; defaultValue: string; matches: string[] }[]; + sliceAutoFields: { name: string; tsType: string; typeId: Identifier; sliceNames: string[] }[]; params: { name: string; tsType: string; typeId: Identifier }[]; accessors: { name: string; tsType: string; typeId: Identifier }[]; }; @@ -78,21 +80,22 @@ const tryPromoteChoice = ( promotedChoices.add(choiceName); }; -const collectRequiredSliceMatches = (field: RegularField): Record[] | undefined => { +const collectRequiredSliceNames = (field: RegularField): string[] | undefined => { if (!field.array || !field.slicing?.slices) return undefined; - const matches = Object.values(field.slicing.slices) - .filter((s) => s.min !== undefined && s.min >= 1 && s.match && Object.keys(s.match).length > 0) - .map((s) => s.match as Record); - return matches.length > 0 ? matches : undefined; + const names = Object.entries(field.slicing.slices) + .filter(([_, s]) => s.min !== undefined && s.min >= 1 && s.match && Object.keys(s.match).length > 0) + .map(([name]) => name); + return names.length > 0 ? names : undefined; }; -const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFactoryInfo => { +const collectProfileFactoryInfo = (tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema): ProfileFactoryInfo => { const autoFields: ProfileFactoryInfo["autoFields"] = []; const sliceAutoFields: ProfileFactoryInfo["sliceAutoFields"] = []; const params: ProfileFactoryInfo["params"] = []; const autoAccessors: ProfileFactoryInfo["accessors"] = []; const fields = flatProfile.fields ?? {}; const promotedChoices = new Set(); + const resolveRef = tsIndex.findLastSpecializationByIdentifier; if (isResourceIdentifier(flatProfile.base)) { autoFields.push({ name: "resourceType", value: JSON.stringify(flatProfile.base.name) }); @@ -111,24 +114,22 @@ const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFacto const value = JSON.stringify(field.valueConstraint.value); autoFields.push({ name, value: field.array ? `[${value}]` : value }); if (isNotChoiceDeclarationField(field) && field.type) { - const tsType = resolveFieldTsType("", "", field) + (field.array ? "[]" : ""); + const tsType = resolveFieldTsType("", "", field, resolveRef) + (field.array ? "[]" : ""); autoAccessors.push({ name, tsType, typeId: field.type }); } continue; } if (isNotChoiceDeclarationField(field)) { - const requiredMatches = collectRequiredSliceMatches(field); - if (requiredMatches) { - const defaultValue = `[${requiredMatches.map((m) => JSON.stringify(m)).join(",")}]`; + const sliceNames = collectRequiredSliceNames(field); + if (sliceNames) { if (field.type) { - const tsType = resolveFieldTsType("", "", field) + (field.array ? "[]" : ""); + const tsType = resolveFieldTsType("", "", field, resolveRef) + (field.array ? "[]" : ""); sliceAutoFields.push({ name, tsType, typeId: field.type, - defaultValue, - matches: requiredMatches.map((m) => JSON.stringify(m)), + sliceNames, }); autoAccessors.push({ name, tsType, typeId: field.type }); } @@ -137,11 +138,32 @@ const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFacto } if (field.required) { - const tsType = resolveFieldTsType("", "", field) + (field.array ? "[]" : ""); + const tsType = resolveFieldTsType("", "", field, resolveRef) + (field.array ? "[]" : ""); params.push({ name, tsType, typeId: field.type }); } } + // Include base-type required fields not already covered by profile constraints + const coveredFields = new Set([ + ...autoFields.map((f) => f.name), + ...sliceAutoFields.map((f) => f.name), + ...params.map((f) => f.name), + ...promotedChoices, + ]); + const baseSchema = tsIndex.resolve(flatProfile.base); + if (baseSchema && "fields" in baseSchema && baseSchema.fields) { + for (const [name, field] of Object.entries(baseSchema.fields)) { + if (coveredFields.has(name)) continue; + if (!field.required) continue; + if (isChoiceInstanceField(field)) continue; + if (isChoiceDeclarationField(field)) continue; + if (isNotChoiceDeclarationField(field) && field.type) { + const tsType = resolveFieldTsType("", "", field, resolveRef) + (field.array ? "[]" : ""); + params.push({ name, tsType, typeId: field.type }); + } + } + } + const accessors = [...autoAccessors, ...collectChoiceAccessors(flatProfile, promotedChoices)]; return { autoFields, sliceAutoFields, params, accessors }; }; @@ -242,324 +264,6 @@ const tsTypeForProfileField = ( return tsType; }; -export const generateProfileHelpersModule = (w: TypeScript) => { - w.cat("profile-helpers.ts", () => { - w.generateDisclaimer(); - w.curlyBlock( - ["export const", "isRecord", "=", "(value: unknown): value is Record", "=>"], - () => { - w.lineSM('return value !== null && typeof value === "object" && !Array.isArray(value)'); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "getOrCreateObjectAtPath", - "=", - "(root: Record, path: string[]): Record", - "=>", - ], - () => { - w.lineSM("let current: Record = root"); - w.curlyBlock(["for (const", "segment", "of", "path)"], () => { - w.curlyBlock(["if", "(Array.isArray(current[segment]))"], () => { - w.lineSM("const list = current[segment] as unknown[]"); - w.curlyBlock(["if", "(list.length === 0)"], () => { - w.lineSM("list.push({})"); - }); - w.lineSM("current = list[0] as Record"); - }); - w.curlyBlock(["else"], () => { - w.curlyBlock(["if", "(!isRecord(current[segment]))"], () => { - w.lineSM("current[segment] = {}"); - }); - w.lineSM("current = current[segment] as Record"); - }); - }); - w.lineSM("return current"); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "mergeMatch", - "=", - "(target: Record, match: Record): void", - "=>", - ], - () => { - w.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { - w.curlyBlock( - ["if", '(key === "__proto__" || key === "constructor" || key === "prototype")'], - () => { - w.lineSM("continue"); - }, - ); - w.curlyBlock(["if", "(isRecord(matchValue))"], () => { - w.curlyBlock(["if", "(isRecord(target[key]))"], () => { - w.lineSM("mergeMatch(target[key] as Record, matchValue)"); - }); - w.curlyBlock(["else"], () => { - w.lineSM("target[key] = { ...matchValue }"); - }); - }); - w.curlyBlock(["else"], () => { - w.lineSM("target[key] = matchValue"); - }); - }); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "applySliceMatch", - "=", - ">(input: T, match: Record): T", - "=>", - ], - () => { - w.lineSM("const result = { ...input } as Record"); - w.lineSM("mergeMatch(result, match)"); - w.lineSM("return result as T"); - }, - ); - w.line(); - w.curlyBlock(["export const", "matchesValue", "=", "(value: unknown, match: unknown): boolean", "=>"], () => { - w.curlyBlock(["if", "(Array.isArray(match))"], () => { - w.curlyBlock(["if", "(!Array.isArray(value))"], () => w.lineSM("return false")); - w.lineSM("return match.every((matchItem) => value.some((item) => matchesValue(item, matchItem)))"); - }); - w.curlyBlock(["if", "(isRecord(match))"], () => { - w.curlyBlock(["if", "(!isRecord(value))"], () => w.lineSM("return false")); - w.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { - w.curlyBlock(["if", "(!matchesValue((value as Record)[key], matchValue))"], () => { - w.lineSM("return false"); - }); - }); - w.lineSM("return true"); - }); - w.lineSM("return value === match"); - }); - w.line(); - w.curlyBlock( - ["export const", "matchesSlice", "=", "(value: unknown, match: Record): boolean", "=>"], - () => { - w.lineSM("return matchesValue(value, match)"); - }, - ); - w.line(); - // extractComplexExtension - extract sub-extension values from complex extension - w.curlyBlock( - [ - "export const", - "extractComplexExtension", - "=", - "(extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, config: Array<{ name: string; valueField: string; isArray: boolean }>): Record | undefined", - "=>", - ], - () => { - w.lineSM("if (!extension?.extension) return undefined"); - w.lineSM("const result: Record = {}"); - w.curlyBlock(["for (const", "{ name, valueField, isArray }", "of", "config)"], () => { - w.lineSM("const subExts = extension.extension.filter(e => e.url === name)"); - w.curlyBlock(["if", "(isArray)"], () => { - w.lineSM("result[name] = subExts.map(e => (e as Record)[valueField])"); - }); - w.curlyBlock(["else if", "(subExts[0])"], () => { - w.lineSM("result[name] = (subExts[0] as Record)[valueField]"); - }); - }); - w.lineSM("return result"); - }, - ); - w.line(); - // extractSliceSimplified - remove match keys from slice (reverse of applySliceMatch) - w.curlyBlock( - [ - "export const", - "extractSliceSimplified", - "=", - ">(slice: T, matchKeys: string[]): Partial", - "=>", - ], - () => { - w.lineSM("const result = { ...slice } as Record"); - w.curlyBlock(["for (const", "key", "of", "matchKeys)"], () => { - w.lineSM("delete result[key]"); - }); - w.lineSM("return result as Partial"); - }, - ); - w.line(); - // wrapSliceChoice - wrap flat input under the single choice variant key for setter - w.curlyBlock( - [ - "export const", - "wrapSliceChoice", - "=", - "(input: Record, choiceVariant: string): Record", - "=>", - ], - () => { - w.lineSM("if (Object.keys(input).length === 0) return input"); - w.lineSM("return { [choiceVariant]: input }"); - }, - ); - w.line(); - // flattenSliceChoice - strip match keys and flatten choice variant into parent for getter - w.curlyBlock( - [ - "export const", - "flattenSliceChoice", - "=", - "(slice: Record, matchKeys: string[], choiceVariant: string): Record", - "=>", - ], - () => { - w.lineSM("const result = { ...slice } as Record"); - w.curlyBlock(["for (const", "key", "of", "matchKeys)"], () => { - w.lineSM("delete result[key]"); - }); - w.lineSM("const variantValue = result[choiceVariant]"); - w.lineSM("delete result[choiceVariant]"); - w.curlyBlock(["if", "(isRecord(variantValue))"], () => { - w.lineSM("Object.assign(result, variantValue)"); - }); - w.lineSM("return result"); - }, - ); - w.line(); - // --- Validation helpers --- - w.curlyBlock( - [ - "export const", - "validateRequired", - "=", - "(r: Record, field: string, path: string): string | undefined", - "=>", - ], - () => { - w.lineSM( - "return r[field] === undefined || r[field] === null ? `${path}: required field '${field}' is missing` : undefined", - ); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "validateExcluded", - "=", - "(r: Record, field: string, path: string): string | undefined", - "=>", - ], - () => { - w.lineSM("return r[field] !== undefined ? `${path}: field '${field}' must not be present` : undefined"); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "validateFixedValue", - "=", - "(r: Record, field: string, expected: unknown, path: string): string | undefined", - "=>", - ], - () => { - w.lineSM( - "return matchesValue(r[field], expected) ? undefined : `${path}: field '${field}' does not match expected fixed value`", - ); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "validateSliceCardinality", - "=", - "(items: unknown[] | undefined, match: Record, sliceName: string, min: number, max: number, path: string): string[]", - "=>", - ], - () => { - w.lineSM("const count = (items ?? []).filter(item => matchesSlice(item, match)).length"); - w.lineSM("const errors: string[] = []"); - w.curlyBlock(["if", "(count < min)"], () => { - w.lineSM( - "errors.push(`${path}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`)", - ); - }); - w.curlyBlock(["if", "(max > 0 && count > max)"], () => { - w.lineSM( - "errors.push(`${path}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`)", - ); - }); - w.lineSM("return errors"); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "validateEnum", - "=", - "(value: unknown, allowed: string[], field: string, path: string): string | undefined", - "=>", - ], - () => { - w.lineSM("if (value === undefined || value === null) return undefined"); - w.curlyBlock(["if", "(typeof value === 'string')"], () => { - w.lineSM( - "return allowed.includes(value) ? undefined : `${path}: field '${field}' value '${value}' is not in allowed values`", - ); - }); - w.lineSM("const rec = value as Record"); - w.comment("Coding"); - w.curlyBlock(["if", "(typeof rec.code === 'string' && rec.system !== undefined)"], () => { - w.lineSM( - "return allowed.includes(rec.code) ? undefined : `${path}: field '${field}' code '${rec.code}' is not in allowed values`", - ); - }); - w.comment("CodeableConcept"); - w.curlyBlock(["if", "(Array.isArray(rec.coding))"], () => { - w.lineSM( - "const codes = (rec.coding as Array>).map(c => c.code as string).filter(Boolean)", - ); - w.lineSM("const hasValid = codes.some(c => allowed.includes(c))"); - w.lineSM( - "return hasValid ? undefined : `${path}: field '${field}' has no coding with an allowed code`", - ); - }); - w.lineSM("return undefined"); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "validateReference", - "=", - "(value: unknown, allowed: string[], field: string, path: string): string | undefined", - "=>", - ], - () => { - w.lineSM("if (value === undefined || value === null) return undefined"); - w.lineSM("const ref = (value as Record).reference as string | undefined"); - w.lineSM("if (!ref) return undefined"); - w.lineSM("const slashIdx = ref.indexOf('/')"); - w.lineSM("if (slashIdx === -1) return undefined"); - w.lineSM("const refType = ref.slice(0, slashIdx)"); - w.lineSM( - "return allowed.includes(refType) ? undefined : `${path}: field '${field}' references '${refType}' but only ${allowed.join(', ')} are allowed`", - ); - }, - ); - }); -}; - const generateProfileHelpersImport = ( w: TypeScript, options: { @@ -569,23 +273,27 @@ const generateProfileHelpersImport = ( needsSliceExtraction: boolean; needsSliceChoiceHelpers: boolean; needsValidation: boolean; + needsRegisterProfile: boolean; }, ) => { const imports: string[] = []; + if (options.needsRegisterProfile) { + imports.push("ensureProfile"); + } if (options.needsSliceHelpers) { - imports.push("applySliceMatch", "matchesSlice"); + imports.push("applySliceMatch", "matchesValue", "setArraySlice", "getArraySlice", "ensureSliceDefaults"); } if (options.needsGetOrCreateObjectAtPath) { - imports.push("getOrCreateObjectAtPath"); + imports.push("ensurePath"); } if (options.needsExtensionExtraction) { imports.push("extractComplexExtension"); } if (options.needsSliceExtraction) { - imports.push("extractSliceSimplified"); + imports.push("stripMatchKeys"); } if (options.needsSliceChoiceHelpers) { - imports.push("wrapSliceChoice", "flattenSliceChoice"); + imports.push("wrapSliceChoice", "unwrapSliceChoice"); } if (options.needsValidation) { imports.push( @@ -595,6 +303,7 @@ const generateProfileHelpersImport = ( "validateSliceCardinality", "validateEnum", "validateReference", + "validateChoiceRequired", ); } if (imports.length > 0) { @@ -703,7 +412,7 @@ export const generateProfileImports = (w: TypeScript, tsIndex: TypeSchemaIndex, const needsExtensionType = collectTypesFromExtensions(tsIndex, flatProfile, addType); collectTypesFromFieldOverrides(tsIndex, flatProfile, addType); - const factoryInfo = collectProfileFactoryInfo(flatProfile); + const factoryInfo = collectProfileFactoryInfo(tsIndex, flatProfile); for (const param of factoryInfo.params) addType(param.typeId); for (const f of factoryInfo.sliceAutoFields) addType(f.typeId); for (const accessor of factoryInfo.accessors) addType(accessor.typeId); @@ -908,7 +617,7 @@ export const generateProfileClass = ( // Check if we have an override interface (narrowed types) const hasOverrideInterface = detectFieldOverrides(w, tsIndex, flatProfile).size > 0; - const factoryInfo = collectProfileFactoryInfo(flatProfile); + const factoryInfo = collectProfileFactoryInfo(tsIndex, flatProfile); // Determine which helpers are actually needed const needsSliceHelpers = sliceDefs.length > 0 || factoryInfo.sliceAutoFields.length > 0; @@ -922,6 +631,8 @@ export const generateProfileClass = ( const needsSliceChoiceHelpers = sliceDefs.some((s) => s.constrainedChoice); const needsValidation = Object.keys(flatProfile.fields ?? {}).length > 0; + const hasMeta = tsIndex.isWithMetaField(flatProfile); + const needsRegisterProfile = !!schema?.identifier.url && hasMeta; if ( needsSliceHelpers || @@ -929,7 +640,8 @@ export const generateProfileClass = ( needsExtensionExtraction || needsSliceExtraction || needsSliceChoiceHelpers || - needsValidation + needsValidation || + needsRegisterProfile ) { generateProfileHelpersImport(w, { needsGetOrCreateObjectAtPath, @@ -938,6 +650,7 @@ export const generateProfileClass = ( needsSliceExtraction, needsSliceChoiceHelpers, needsValidation, + needsRegisterProfile, }); w.line(); } @@ -973,17 +686,19 @@ export const generateProfileClass = ( w.line(`static readonly canonicalUrl = ${JSON.stringify(canonicalUrl)}`); w.line(); } + for (const sliceDef of sliceDefs) { + const staticName = `${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`; + w.line( + `private static readonly ${staticName}: Record = ${JSON.stringify(sliceDef.match)}`, + ); + } + if (sliceDefs.length > 0) w.line(); w.line(`private resource: ${tsBaseResourceName}`); w.line(); w.curlyBlock(["constructor", `(resource: ${tsBaseResourceName})`], () => { w.line("this.resource = resource"); - if (canonicalUrl && isResourceIdentifier(flatProfile.base)) { - w.line(`const r = resource as unknown as Record`); - w.line(`const meta = (r.meta ??= {}) as Record`); - w.line(`const profiles = (meta.profile ??= []) as string[]`); - w.line( - `if (!profiles.includes(${JSON.stringify(canonicalUrl)})) profiles.push(${JSON.stringify(canonicalUrl)})`, - ); + if (canonicalUrl && hasMeta) { + w.line(`ensureProfile(resource, ${JSON.stringify(canonicalUrl)})`); } }); w.line(); @@ -994,23 +709,31 @@ export const generateProfileClass = ( w.curlyBlock(["static", "createResource", `(${paramSignature})`, `: ${tsBaseResourceName}`], () => { // Generate merge logic for slice auto-fields for (const f of factoryInfo.sliceAutoFields) { - const matchExprs = f.matches.map((m) => `${m} as Record`); - w.line(`const ${f.name}Defaults = ${f.defaultValue} as unknown[]`); - w.line(`const ${f.name}WithDefaults = [...(args.${f.name} ?? [])] as unknown[]`); - for (let i = 0; i < matchExprs.length; i++) { - w.line( - `if (!${f.name}WithDefaults.some(item => matchesSlice(item, ${matchExprs[i]}))) ${f.name}WithDefaults.push(${f.name}Defaults[${i}]!)`, - ); - } + const matchRefs = f.sliceNames.map((s) => `${profileClassName}.${tsSliceStaticName(s)}SliceMatch`); + w.line(`const ${f.name}WithDefaults = ensureSliceDefaults(`); + w.indentBlock(() => { + w.line(`[...(args.${f.name} ?? [])],`); + for (const ref of matchRefs) { + w.line(`${ref},`); + } + }); + w.line(")"); + } + if (factoryInfo.sliceAutoFields.length > 0) { + w.line(); + } + if (isPrimitiveIdentifier(flatProfile.base)) { + w.line(`const resource = undefined as unknown as ${tsBaseResourceName}`); + } else { + w.curlyBlock(["const resource ="], () => { + for (const f of allFields) { + w.line(`${f.name}: ${f.value},`); + } + if (canonicalUrl && hasMeta) { + w.line(`meta: { profile: [${profileClassName}.canonicalUrl] },`); + } + }, [` as unknown as ${tsBaseResourceName}`]); } - w.curlyBlock([`const resource: ${tsBaseResourceName} =`], () => { - for (const f of allFields) { - w.line(`${f.name}: ${f.value},`); - } - if (canonicalUrl && isResourceIdentifier(flatProfile.base)) { - w.line(`meta: { profile: [${JSON.stringify(canonicalUrl)}] },`); - } - }, [` as unknown as ${tsBaseResourceName}`]); w.line("return resource"); }); w.line(); @@ -1023,7 +746,12 @@ export const generateProfileClass = ( w.line("return this.resource"); }); w.line(); - // Getter and setter methods for required profile fields + // -- Field accessors section -- + const hasFieldAccessors = factoryInfo.params.length > 0 || factoryInfo.accessors.length > 0; + if (hasFieldAccessors) { + w.line("// Field accessors"); + w.line(); + } for (const p of factoryInfo.params) { const methodSuffix = uppercaseFirstLetter(p.name); w.curlyBlock([`get${methodSuffix}`, "()", `: ${p.tsType} | undefined`], () => { @@ -1089,13 +817,20 @@ export const generateProfileClass = ( w.line(); } + // -- Slices and extensions section -- + const hasSlicesOrExtensions = extensions.length > 0 || sliceDefs.length > 0; + if (hasSlicesOrExtensions) { + w.line("// Slices and extensions"); + w.line(); + } + generateExtensionSetterMethods(w, extensions, extensionMethodNames, tsProfileName); for (const sliceDef of sliceDefs) { const methodName = sliceMethodNames.get(sliceDef) ?? tsQualifiedSliceMethodName(sliceDef.fieldName, sliceDef.sliceName); const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); - const matchLiteral = JSON.stringify(sliceDef.match); + const matchRef = `${profileClassName}.${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`; const tsField = tsFieldName(sliceDef.fieldName); const fieldAccess = tsGet("this.resource", tsField); // Make input optional when there are no required fields (input can be empty object) @@ -1103,31 +838,19 @@ export const generateProfileClass = ( ? `(input?: ${typeName}): this` : `(input: ${typeName}): this`; w.curlyBlock(["public", methodName, paramSignature], () => { - w.line(`const match = ${matchLiteral} as Record`); - // Use empty object as default when input is optional - const inputExpr = sliceDef.inputOptional - ? "(input ?? {}) as Record" - : "input as Record"; + w.line(`const match = ${matchRef}`); + const inputExpr = sliceDef.inputOptional ? "input ?? {}" : "input"; if (sliceDef.constrainedChoice) { const cc = sliceDef.constrainedChoice; w.line( - `const value = applySliceMatch(wrapSliceChoice(${inputExpr}, ${JSON.stringify(cc.variant)}), match) as unknown as ${sliceDef.baseType}`, + `const wrapped = wrapSliceChoice<${sliceDef.baseType}>(${inputExpr}, ${JSON.stringify(cc.variant)})`, ); + w.line(`const value = applySliceMatch<${sliceDef.baseType}>(wrapped, match)`); } else { - w.line(`const value = applySliceMatch(${inputExpr}, match) as unknown as ${sliceDef.baseType}`); + w.line(`const value = applySliceMatch<${sliceDef.baseType}>(${inputExpr}, match)`); } if (sliceDef.array) { - w.line(`const list = (${fieldAccess} ??= [])`); - w.line("const index = list.findIndex((item) => matchesSlice(item, match))"); - w.line("if (index === -1) {"); - w.indentBlock(() => { - w.line("list.push(value)"); - }); - w.line("} else {"); - w.indentBlock(() => { - w.line("list[index] = value"); - }); - w.line("}"); + w.line(`setArraySlice(${fieldAccess} ??= [], match, value)`); } else { w.line(`${fieldAccess} = value`); } @@ -1157,7 +880,7 @@ export const generateProfileClass = ( w.line(`const ext = this.resource.extension?.find(e => e.url === "${ext.url}")`); } else { w.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + `const target = ensurePath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, ); w.line( `const ext = (target.extension as Extension[] | undefined)?.find(e => e.url === "${ext.url}")`, @@ -1212,7 +935,7 @@ export const generateProfileClass = ( w.line(`return this.resource.extension?.find(e => e.url === "${ext.url}")`); } else { w.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + `const target = ensurePath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, ); w.line( `return (target.extension as Extension[] | undefined)?.find(e => e.url === "${ext.url}")`, @@ -1233,7 +956,7 @@ export const generateProfileClass = ( if (generatedGetMethods.has(getMethodName)) continue; generatedGetMethods.add(getMethodName); const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); - const matchLiteral = JSON.stringify(sliceDef.match); + const matchRef = `${profileClassName}.${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`; const matchKeys = JSON.stringify(Object.keys(sliceDef.match)); const tsField = tsFieldName(sliceDef.fieldName); const fieldAccess = tsGet("this.resource", tsField); @@ -1241,14 +964,12 @@ export const generateProfileClass = ( // Helper to find the slice item const generateSliceLookup = () => { - w.line(`const match = ${matchLiteral} as Record`); + w.line(`const match = ${matchRef}`); if (sliceDef.array) { - w.line(`const list = ${fieldAccess}`); - w.line("if (!list) return undefined"); - w.line("const item = list.find((item) => matchesSlice(item, match))"); + w.line(`const item = getArraySlice(${fieldAccess}, match)`); } else { w.line(`const item = ${fieldAccess}`); - w.line("if (!item || !matchesSlice(item, match)) return undefined"); + w.line("if (!item || !matchesValue(item, match)) return undefined"); } }; @@ -1260,13 +981,9 @@ export const generateProfileClass = ( } if (sliceDef.constrainedChoice) { const cc = sliceDef.constrainedChoice; - w.line( - `return flattenSliceChoice(item as unknown as Record, ${matchKeys}, ${JSON.stringify(cc.variant)}) as ${typeName}`, - ); + w.line(`return unwrapSliceChoice<${typeName}>(item, ${matchKeys}, ${JSON.stringify(cc.variant)})`); } else { - w.line( - `return extractSliceSimplified(item as unknown as Record, ${matchKeys}) as ${typeName}`, - ); + w.line(`return stripMatchKeys<${typeName}>(item, ${matchKeys})`); } }); w.line(); @@ -1274,53 +991,49 @@ export const generateProfileClass = ( // Raw getter (full FHIR type) w.curlyBlock(["public", getRawMethodName, `(): ${baseType} | undefined`], () => { generateSliceLookup(); - if (sliceDef.array) { - w.line("return item"); - } else { - w.line("return item"); - } + w.line("return item"); }); w.line(); } - generateValidateMethod(w, flatProfile); + // -- Validation section -- + if (needsValidation) { + w.line("// Validation"); + w.line(); + } + generateValidateMethod(w, tsIndex, flatProfile); }); w.line(); }; -const emitRegularFieldValidation = ( - w: TypeScript, +const collectRegularFieldValidation = ( + exprs: string[], name: string, field: RegularField | ChoiceFieldInstance, - profileName: string, + resolveRef: (ref: Identifier) => Identifier, ) => { if (field.excluded) { - w.line(`{ const e = validateExcluded(r, ${JSON.stringify(name)}, "${profileName}"); if (e) errors.push(e) }`); + exprs.push(`...validateExcluded(res, profileName, ${JSON.stringify(name)})`); return; } if (field.required) { - w.line(`{ const e = validateRequired(r, ${JSON.stringify(name)}, "${profileName}"); if (e) errors.push(e) }`); + exprs.push(`...validateRequired(res, profileName, ${JSON.stringify(name)})`); } if (field.valueConstraint) { - const expected = JSON.stringify(field.valueConstraint.value); - w.line( - `{ const e = validateFixedValue(r, ${JSON.stringify(name)}, ${expected}, "${profileName}"); if (e) errors.push(e) }`, + exprs.push( + `...validateFixedValue(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(field.valueConstraint.value)})`, ); } if (field.enum && !field.enum.isOpen) { - const values = JSON.stringify(field.enum.values); - w.line( - `{ const e = validateEnum(r[${JSON.stringify(name)}], ${values}, ${JSON.stringify(name)}, "${profileName}"); if (e) errors.push(e) }`, - ); + exprs.push(`...validateEnum(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(field.enum.values)})`); } if (field.reference && field.reference.length > 0) { - const refNames = JSON.stringify(field.reference.map((ref) => ref.name)); - w.line( - `{ const e = validateReference(r[${JSON.stringify(name)}], ${refNames}, ${JSON.stringify(name)}, "${profileName}"); if (e) errors.push(e) }`, + exprs.push( + `...validateReference(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(field.reference.map((ref) => resolveRef(ref).name))})`, ); } @@ -1331,36 +1044,40 @@ const emitRegularFieldValidation = ( if (Object.keys(match).length === 0) continue; const min = slice.min ?? 0; const max = slice.max ?? 0; - w.line( - `errors.push(...validateSliceCardinality(r[${JSON.stringify(name)}] as unknown[] | undefined, ${JSON.stringify(match)}, ${JSON.stringify(sliceName)}, ${min}, ${max}, "${profileName}.${name}"))`, + exprs.push( + `...validateSliceCardinality(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(match)}, ${JSON.stringify(sliceName)}, ${min}, ${max})`, ); } } }; -const generateValidateMethod = (w: TypeScript, flatProfile: ProfileTypeSchema) => { +const generateValidateMethod = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { const fields = flatProfile.fields ?? {}; const profileName = flatProfile.identifier.name; - w.curlyBlock(["validate", "()", ": string[]"], () => { - w.line("const errors: string[] = []"); - w.line("const r = this.resource as unknown as Record"); + w.curlyBlock(["validate(): string[]"], () => { + w.line(`const profileName = "${profileName}"`); + w.line("const res = this.resource as unknown as Record"); + + const exprs: string[] = []; for (const [name, field] of Object.entries(fields)) { if (isChoiceInstanceField(field)) continue; if (isChoiceDeclarationField(field)) { - if (field.required) { - const choiceNames = field.choices; - const checks = choiceNames.map((c) => `r[${JSON.stringify(c)}] !== undefined`).join(" || "); - w.curlyBlock(["if", `(!(${checks}))`], () => { - w.line(`errors.push("${name}: at least one of ${choiceNames.join(", ")} is required")`); - }); - } + if (field.required) + exprs.push(`...validateChoiceRequired(res, profileName, ${JSON.stringify(field.choices)})`); continue; } - emitRegularFieldValidation(w, name, field, profileName); + collectRegularFieldValidation(exprs, name, field, tsIndex.findLastSpecializationByIdentifier); + } + + if (exprs.length === 0) { + w.line("return []"); + } else { + w.squareBlock(["return"], () => { + for (const expr of exprs) w.line(`${expr},`); + }); } - w.line("return errors"); }); w.line(); }; @@ -1405,7 +1122,7 @@ const generateExtensionSetterMethods = ( w.line(`list.push({ url: "${ext.url}", extension: subExtensions })`); } else { w.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + `const target = ensurePath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, ); w.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); w.line(`(target.extension as Extension[]).push({ url: "${ext.url}", extension: subExtensions })`); @@ -1424,7 +1141,7 @@ const generateExtensionSetterMethods = ( w.line(`list.push(${extLiteral})`); } else { w.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( + `const target = ensurePath(this.resource as unknown as Record, ${JSON.stringify( targetPath, )})`, ); @@ -1440,7 +1157,7 @@ const generateExtensionSetterMethods = ( w.line(`list.push({ url: "${ext.url}", ...value })`); } else { w.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( + `const target = ensurePath(this.resource as unknown as Record, ${JSON.stringify( targetPath, )})`, ); diff --git a/src/api/writer-generator/typescript/utils.ts b/src/api/writer-generator/typescript/utils.ts index aa9e13827..aec1de7f5 100644 --- a/src/api/writer-generator/typescript/utils.ts +++ b/src/api/writer-generator/typescript/utils.ts @@ -62,6 +62,7 @@ export const resolveFieldTsType = ( schemaName: string, tsName: string, field: RegularField | ChoiceFieldInstance, + resolveRef?: (ref: Identifier) => Identifier, ): string => { const rewriteFieldType = rewriteFieldTypeDefs[schemaName]?.[tsName]; if (rewriteFieldType) return rewriteFieldType(); @@ -72,7 +73,10 @@ export const resolveFieldTsType = ( return tsEnumType(field.enum); } if (field.reference && field.reference.length > 0) { - const references = field.reference.map((ref) => `"${ref.name}"`).join(" | "); + const references = field.reference + .map((ref) => (resolveRef ? resolveRef(ref) : ref)) + .map((ref) => `"${ref.name}"`) + .join(" | "); return `Reference<${references}>`; } if (isPrimitiveIdentifier(field.type)) return resolvePrimitiveType(field.type.name); diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts index 939c13e4c..70c01914e 100644 --- a/src/api/writer-generator/typescript/writer.ts +++ b/src/api/writer-generator/typescript/writer.ts @@ -1,3 +1,5 @@ +import * as Path from "node:path"; +import { fileURLToPath } from "node:url"; import { Writer, type WriterOptions } from "@root/api/writer-generator/writer"; import { type CanonicalUrl, @@ -28,13 +30,21 @@ import { } from "./name"; import { generateProfileClass, - generateProfileHelpersModule, generateProfileImports, generateProfileIndexFile, generateProfileOverrideInterface, } from "./profile"; import { resolveFieldTsType } from "./utils"; +export const resolveTsAssets = (fn: string) => { + const __dirname = Path.dirname(fileURLToPath(import.meta.url)); + const __filename = fileURLToPath(import.meta.url); + if (__filename.endsWith("dist/index.js")) { + return Path.resolve(__dirname, "..", "assets", "api", "writer-generator", "typescript", fn); + } + return Path.resolve(__dirname, "../../../..", "assets", "api", "writer-generator", "typescript", fn); +}; + export type TypeScriptOptions = { /** openResourceTypeSet -- for resource families (Resource, DomainResource) use open set for resourceType field. * @@ -46,6 +56,10 @@ export type TypeScriptOptions = { } & WriterOptions; export class TypeScript extends Writer { + constructor(options: TypeScriptOptions) { + super({ ...options, resolveAssets: options.resolveAssets ?? resolveTsAssets }); + } + tsImportType(tsPackageName: string, ...entities: string[]) { this.lineSM(`import type { ${entities.join(", ")} } from "${tsPackageName}"`); } @@ -296,7 +310,7 @@ export class TypeScript extends Writer { this.cd("/", () => { if (hasProfiles) { - generateProfileHelpersModule(this); + this.cp("profile-helpers.ts", "profile-helpers.ts"); } for (const [packageName, packageSchemas] of Object.entries(grouped)) { From b2f4b409dbc34e3dbb9baef757e466267f3d623d Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 6 Mar 2026 13:32:01 +0100 Subject: [PATCH 2/6] Update test snapshots for profile refactor --- .../__snapshots__/typescript.test.ts.snap | 239 ++++++++---------- 1 file changed, 107 insertions(+), 132 deletions(-) diff --git a/test/api/write-generator/__snapshots__/typescript.test.ts.snap b/test/api/write-generator/__snapshots__/typescript.test.ts.snap index c100b9264..59f846294 100644 --- a/test/api/write-generator/__snapshots__/typescript.test.ts.snap +++ b/test/api/write-generator/__snapshots__/typescript.test.ts.snap @@ -329,7 +329,7 @@ export interface observation_bodyweight extends Observation { export type Observation_bodyweight_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_bodyweightProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -341,14 +341,13 @@ export type observation_bodyweightProfileParams = { export class observation_bodyweightProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/bodyweight" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/bodyweight")) profiles.push("http://hl7.org/fhir/StructureDefinition/bodyweight") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/bodyweight") } static from (resource: Observation) : observation_bodyweightProfile { @@ -356,16 +355,18 @@ export class observation_bodyweightProfile { } static createResource (args: observation_bodyweightProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_bodyweightProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}, category: categoryWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bodyweight"] }, + meta: { profile: [observation_bodyweightProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -378,6 +379,8 @@ export class observation_bodyweightProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -445,53 +448,46 @@ export class observation_bodyweightProfile { return this.resource as observation_bodyweight } + // Slices and extensions + public setVSCat (input?: Observation_bodyweight_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bodyweightProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): Observation_bodyweight_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bodyweightProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_bodyweight_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bodyweightProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-bodyweight"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-bodyweight.category")) - { const e = validateRequired(r, "code", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}, "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-bodyweight"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-bodyweight"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "observation-bodyweight" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ] } } @@ -520,7 +516,7 @@ export type Observation_bp_Category_VSCatSliceInput = Omit; export type Observation_bp_Component_DiastolicBPSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_bpProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -533,14 +529,15 @@ export type observation_bpProfileParams = { export class observation_bpProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/bp" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private static readonly SystolicBPSliceMatch: Record = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} + private static readonly DiastolicBPSliceMatch: Record = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/bp")) profiles.push("http://hl7.org/fhir/StructureDefinition/bp") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/bp") } static from (resource: Observation) : observation_bpProfile { @@ -548,21 +545,24 @@ export class observation_bpProfile { } static createResource (args: observation_bpProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const componentDefaults = [{"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}},{"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}] as unknown[] - const componentWithDefaults = [...(args.component ?? [])] as unknown[] - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record))) componentWithDefaults.push(componentDefaults[0]!) - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record))) componentWithDefaults.push(componentDefaults[1]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_bpProfile.VSCatSliceMatch, + ) + const componentWithDefaults = ensureSliceDefaults( + [...(args.component ?? [])], + observation_bpProfile.SystolicBPSliceMatch, + observation_bpProfile.DiastolicBPSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, category: categoryWithDefaults, component: componentWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bp"] }, + meta: { profile: [observation_bpProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -575,6 +575,8 @@ export class observation_bpProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -651,115 +653,88 @@ export class observation_bpProfile { return this.resource as observation_bp } + // Slices and extensions + public setVSCat (input?: Observation_bp_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public setSystolicBP (input?: Observation_bp_Component_SystolicBPSliceInput): this { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.SystolicBPSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public setDiastolicBP (input?: Observation_bp_Component_DiastolicBPSliceInput): this { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.DiastolicBPSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public getVSCat (): Observation_bp_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_bp_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } public getSystolicBP (): Observation_bp_Component_SystolicBPSliceInput | undefined { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.SystolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["code"]) as Observation_bp_Component_SystolicBPSliceInput + return stripMatchKeys(item, ["code"]) } public getSystolicBPRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.SystolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) return item } public getDiastolicBP (): Observation_bp_Component_DiastolicBPSliceInput | undefined { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.DiastolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["code"]) as Observation_bp_Component_DiastolicBPSliceInput + return stripMatchKeys(item, ["code"]) } public getDiastolicBPRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.DiastolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-bp"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-bp"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-bp"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-bp.category")) - { const e = validateRequired(r, "code", "observation-bp"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, "observation-bp"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-bp"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-bp"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-bp"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-bp"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}}, "SystolicBP", 1, 1, "observation-bp.component")) - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}, "DiastolicBP", 1, 1, "observation-bp.component")) - return errors + // Validation + + validate(): string[] { + const profileName = "observation-bp" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}}, "SystolicBP", 1, 1), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}, "DiastolicBP", 1, 1), + ] } } From 622194bc847eab91434b06c9871bd4597abf360b Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 6 Mar 2026 13:32:12 +0100 Subject: [PATCH 3/6] Regenerate examples with refactored profile classes --- .../profiles/Extension_birthPlace.ts | 25 +- .../profiles/Extension_birthTime.ts | 25 +- .../profiles/Extension_nationality.ts | 23 +- .../profiles/Extension_own_prefix.ts | 25 +- .../Observation_observation_bodyweight.ts | 86 +++-- .../profiles/Observation_observation_bp.ts | 155 ++++---- .../Observation_observation_vitalsigns.ts | 84 +++-- .../fhir-types/profile-helpers.ts | 339 ++++++++++++++---- examples/typescript-r4/profile-bp.test.ts | 2 +- .../Observation_observation_vitalsigns.ts | 84 +++-- .../Observation_USCoreBloodPressureProfile.ts | 165 ++++----- .../Observation_USCoreBodyWeightProfile.ts | 96 +++-- .../Observation_USCoreVitalSignsProfile.ts | 94 +++-- .../profiles/Patient_USCorePatientProfile.ts | 30 +- .../fhir-types/profile-helpers.ts | 339 ++++++++++++++---- 15 files changed, 972 insertions(+), 600 deletions(-) diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts index 9e5a63ebc..82c139fe0 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts @@ -5,7 +5,7 @@ import type { Address } from "../../hl7-fhir-r4-core/Address"; import type { Extension } from "../../hl7-fhir-r4-core/Extension"; -import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type birthPlaceProfileParams = { valueAddress: Address; @@ -26,7 +26,7 @@ export class birthPlaceProfile { } static createResource (args: birthPlaceProfileParams) : Extension { - const resource: Extension = { + const resource = { url: "http://hl7.org/fhir/StructureDefinition/patient-birthPlace", valueAddress: args.valueAddress, } as unknown as Extension @@ -41,6 +41,8 @@ export class birthPlaceProfile { return this.resource } + // Field accessors + getValueAddress () : Address | undefined { return this.resource.valueAddress as Address | undefined } @@ -59,15 +61,16 @@ export class birthPlaceProfile { return this } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "url", "birthPlace"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "url", "http://hl7.org/fhir/StructureDefinition/patient-birthPlace", "birthPlace"); if (e) errors.push(e) } - if (!(r["valueAddress"] !== undefined)) { - errors.push("value: at least one of valueAddress is required") - } - return errors + // Validation + + validate(): string[] { + const profileName = "birthPlace" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "url"), + ...validateFixedValue(res, profileName, "url", "http://hl7.org/fhir/StructureDefinition/patient-birthPlace"), + ...validateChoiceRequired(res, profileName, ["valueAddress"]), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts index bff1c3abb..53e7203bb 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts @@ -4,7 +4,7 @@ import type { Extension } from "../../hl7-fhir-r4-core/Extension"; -import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type birthTimeProfileParams = { valueDateTime: string; @@ -25,7 +25,7 @@ export class birthTimeProfile { } static createResource (args: birthTimeProfileParams) : Extension { - const resource: Extension = { + const resource = { url: "http://hl7.org/fhir/StructureDefinition/patient-birthTime", valueDateTime: args.valueDateTime, } as unknown as Extension @@ -40,6 +40,8 @@ export class birthTimeProfile { return this.resource } + // Field accessors + getValueDateTime () : string | undefined { return this.resource.valueDateTime as string | undefined } @@ -58,15 +60,16 @@ export class birthTimeProfile { return this } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "url", "birthTime"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "url", "http://hl7.org/fhir/StructureDefinition/patient-birthTime", "birthTime"); if (e) errors.push(e) } - if (!(r["valueDateTime"] !== undefined)) { - errors.push("value: at least one of valueDateTime is required") - } - return errors + // Validation + + validate(): string[] { + const profileName = "birthTime" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "url"), + ...validateFixedValue(res, profileName, "url", "http://hl7.org/fhir/StructureDefinition/patient-birthTime"), + ...validateChoiceRequired(res, profileName, ["valueDateTime"]), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts index 798d58709..07c3ec146 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts @@ -6,7 +6,7 @@ import type { CodeableConcept } from "../../hl7-fhir-r4-core/CodeableConcept"; import type { Extension } from "../../hl7-fhir-r4-core/Extension"; import type { Period } from "../../hl7-fhir-r4-core/Period"; -import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; // CanonicalURL: http://hl7.org/fhir/StructureDefinition/patient-nationality (pkg: hl7.fhir.r4.core#4.0.1) export class nationalityProfile { @@ -23,7 +23,7 @@ export class nationalityProfile { } static createResource () : Extension { - const resource: Extension = { + const resource = { url: "http://hl7.org/fhir/StructureDefinition/patient-nationality", } as unknown as Extension return resource @@ -37,6 +37,8 @@ export class nationalityProfile { return this.resource } + // Field accessors + getUrl () : string | undefined { return this.resource.url as string | undefined } @@ -46,6 +48,8 @@ export class nationalityProfile { return this } + // Slices and extensions + public setCode (value: CodeableConcept): this { const list = (this.resource.extension ??= []) list.push({ url: "code", valueCodeableConcept: value } as Extension) @@ -78,12 +82,15 @@ export class nationalityProfile { return ext } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "url", "nationality"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "url", "http://hl7.org/fhir/StructureDefinition/patient-nationality", "nationality"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "nationality" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "url"), + ...validateFixedValue(res, profileName, "url", "http://hl7.org/fhir/StructureDefinition/patient-nationality"), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts index 9a505e7bb..b3da99461 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts @@ -4,7 +4,7 @@ import type { Extension } from "../../hl7-fhir-r4-core/Extension"; -import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type own_prefixProfileParams = { valueString: string; @@ -25,7 +25,7 @@ export class own_prefixProfile { } static createResource (args: own_prefixProfileParams) : Extension { - const resource: Extension = { + const resource = { url: "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix", valueString: args.valueString, } as unknown as Extension @@ -40,6 +40,8 @@ export class own_prefixProfile { return this.resource } + // Field accessors + getValueString () : string | undefined { return this.resource.valueString as string | undefined } @@ -58,15 +60,16 @@ export class own_prefixProfile { return this } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "url", "own-prefix"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "url", "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix", "own-prefix"); if (e) errors.push(e) } - if (!(r["valueString"] !== undefined)) { - errors.push("value: at least one of valueString is required") - } - return errors + // Validation + + validate(): string[] { + const profileName = "own-prefix" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "url"), + ...validateFixedValue(res, profileName, "url", "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix"), + ...validateChoiceRequired(res, profileName, ["valueString"]), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts index 2449e9d88..58b8c5418 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts @@ -15,7 +15,7 @@ export interface observation_bodyweight extends Observation { export type Observation_bodyweight_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_bodyweightProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -27,14 +27,13 @@ export type observation_bodyweightProfileParams = { export class observation_bodyweightProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/bodyweight" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/bodyweight")) profiles.push("http://hl7.org/fhir/StructureDefinition/bodyweight") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/bodyweight") } static from (resource: Observation) : observation_bodyweightProfile { @@ -42,16 +41,18 @@ export class observation_bodyweightProfile { } static createResource (args: observation_bodyweightProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_bodyweightProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}, category: categoryWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bodyweight"] }, + meta: { profile: [observation_bodyweightProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -64,6 +65,8 @@ export class observation_bodyweightProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -131,53 +134,46 @@ export class observation_bodyweightProfile { return this.resource as observation_bodyweight } + // Slices and extensions + public setVSCat (input?: Observation_bodyweight_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bodyweightProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): Observation_bodyweight_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bodyweightProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_bodyweight_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bodyweightProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-bodyweight"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-bodyweight.category")) - { const e = validateRequired(r, "code", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}, "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-bodyweight"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-bodyweight"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "observation-bodyweight" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts index db4fa14fd..d670fad75 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts @@ -18,7 +18,7 @@ export type Observation_bp_Category_VSCatSliceInput = Omit & Quantity; export type Observation_bp_Component_DiastolicBPSliceInput = Omit & Quantity; -import { applySliceMatch, matchesSlice, extractSliceSimplified, wrapSliceChoice, flattenSliceChoice, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, wrapSliceChoice, unwrapSliceChoice, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_bpProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -31,14 +31,15 @@ export type observation_bpProfileParams = { export class observation_bpProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/bp" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private static readonly SystolicBPSliceMatch: Record = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} + private static readonly DiastolicBPSliceMatch: Record = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/bp")) profiles.push("http://hl7.org/fhir/StructureDefinition/bp") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/bp") } static from (resource: Observation) : observation_bpProfile { @@ -46,21 +47,24 @@ export class observation_bpProfile { } static createResource (args: observation_bpProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const componentDefaults = [{"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}},{"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}] as unknown[] - const componentWithDefaults = [...(args.component ?? [])] as unknown[] - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record))) componentWithDefaults.push(componentDefaults[0]!) - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record))) componentWithDefaults.push(componentDefaults[1]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_bpProfile.VSCatSliceMatch, + ) + const componentWithDefaults = ensureSliceDefaults( + [...(args.component ?? [])], + observation_bpProfile.SystolicBPSliceMatch, + observation_bpProfile.DiastolicBPSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, category: categoryWithDefaults, component: componentWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bp"] }, + meta: { profile: [observation_bpProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -73,6 +77,8 @@ export class observation_bpProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -149,115 +155,90 @@ export class observation_bpProfile { return this.resource as observation_bp } + // Slices and extensions + public setVSCat (input?: Observation_bp_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public setSystolicBP (input?: Observation_bp_Component_SystolicBPSliceInput): this { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const value = applySliceMatch(wrapSliceChoice((input ?? {}) as Record, "valueQuantity"), match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.SystolicBPSliceMatch + const wrapped = wrapSliceChoice(input ?? {}, "valueQuantity") + const value = applySliceMatch(wrapped, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public setDiastolicBP (input?: Observation_bp_Component_DiastolicBPSliceInput): this { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const value = applySliceMatch(wrapSliceChoice((input ?? {}) as Record, "valueQuantity"), match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.DiastolicBPSliceMatch + const wrapped = wrapSliceChoice(input ?? {}, "valueQuantity") + const value = applySliceMatch(wrapped, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public getVSCat (): Observation_bp_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_bp_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } public getSystolicBP (): Observation_bp_Component_SystolicBPSliceInput | undefined { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.SystolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return flattenSliceChoice(item as unknown as Record, ["code"], "valueQuantity") as Observation_bp_Component_SystolicBPSliceInput + return unwrapSliceChoice(item, ["code"], "valueQuantity") } public getSystolicBPRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.SystolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) return item } public getDiastolicBP (): Observation_bp_Component_DiastolicBPSliceInput | undefined { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.DiastolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return flattenSliceChoice(item as unknown as Record, ["code"], "valueQuantity") as Observation_bp_Component_DiastolicBPSliceInput + return unwrapSliceChoice(item, ["code"], "valueQuantity") } public getDiastolicBPRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.DiastolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-bp"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-bp"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-bp"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-bp.category")) - { const e = validateRequired(r, "code", "observation-bp"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, "observation-bp"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-bp"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-bp"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-bp"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-bp"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}}, "SystolicBP", 1, 1, "observation-bp.component")) - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}, "DiastolicBP", 1, 1, "observation-bp.component")) - return errors + // Validation + + validate(): string[] { + const profileName = "observation-bp" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}}, "SystolicBP", 1, 1), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}, "DiastolicBP", 1, 1), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts index ad0e95fa4..8f87ef5a4 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts @@ -14,7 +14,7 @@ export interface observation_vitalsigns extends Observation { export type Observation_vitalsigns_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_vitalsignsProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -27,14 +27,13 @@ export type observation_vitalsignsProfileParams = { export class observation_vitalsignsProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/vitalsigns" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/vitalsigns")) profiles.push("http://hl7.org/fhir/StructureDefinition/vitalsigns") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/vitalsigns") } static from (resource: Observation) : observation_vitalsignsProfile { @@ -42,16 +41,18 @@ export class observation_vitalsignsProfile { } static createResource (args: observation_vitalsignsProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_vitalsignsProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", category: categoryWithDefaults, status: args.status, code: args.code, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/vitalsigns"] }, + meta: { profile: [observation_vitalsignsProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -64,6 +65,8 @@ export class observation_vitalsignsProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -122,52 +125,45 @@ export class observation_vitalsignsProfile { return this.resource as observation_vitalsigns } + // Slices and extensions + public setVSCat (input?: Observation_vitalsigns_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_vitalsignsProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): Observation_vitalsigns_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_vitalsignsProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_vitalsigns_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_vitalsignsProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-vitalsigns"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-vitalsigns.category")) - { const e = validateRequired(r, "code", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-vitalsigns"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-vitalsigns"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "observation-vitalsigns" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ] } } diff --git a/examples/typescript-r4/fhir-types/profile-helpers.ts b/examples/typescript-r4/fhir-types/profile-helpers.ts index ab04e6bd0..4f45db45b 100644 --- a/examples/typescript-r4/fhir-types/profile-helpers.ts +++ b/examples/typescript-r4/fhir-types/profile-helpers.ts @@ -1,12 +1,40 @@ -// WARNING: This file is autogenerated by @atomic-ehr/codegen. -// GitHub: https://github.com/atomic-ehr/codegen -// Any manual changes made to this file may be overwritten. +/** + * Runtime helpers for generated FHIR profile classes. + * + * This file is copied verbatim into every generated TypeScript output and + * imported by profile modules. It provides: + * + * - **Slice helpers** – match, get, set, and default-fill array slices + * defined by a FHIR StructureDefinition. + * - **Extension helpers** – read complex (nested) FHIR extensions into + * plain objects. + * - **Choice-type helpers** – wrap/unwrap polymorphic `value[x]` fields so + * profile classes can expose a flat API. + * - **Validation helpers** – lightweight structural checks that profile + * classes call from their `validate()` method. + * - **Misc utilities** – deep-match, deep-merge, path navigation. + */ +// --------------------------------------------------------------------------- +// General utilities +// --------------------------------------------------------------------------- + +/** Type guard: `value` is a non-null, non-array plain object. */ export const isRecord = (value: unknown): value is Record => { return value !== null && typeof value === "object" && !Array.isArray(value); -} +}; -export const getOrCreateObjectAtPath = (root: Record, path: string[]): Record => { +/** + * Walk `path` segments from `root`, creating intermediate objects (or using + * the first element of an existing array) as needed. Returns the leaf object. + * + * Used by extension setters to reach a nested target inside a resource. + * + * @example + * ensurePath(resource, ["contact", "telecom"]) + * // → resource.contact.telecom (created if absent) + */ +export const ensurePath = (root: Record, path: string[]): Record => { let current: Record = root; for (const segment of path) { if (Array.isArray(current[segment])) { @@ -15,8 +43,7 @@ export const getOrCreateObjectAtPath = (root: Record, path: str list.push({}); } current = list[0] as Record; - } - else { + } else { if (!isRecord(current[segment])) { current[segment] = {}; } @@ -24,8 +51,16 @@ export const getOrCreateObjectAtPath = (root: Record, path: str } } return current; -} +}; + +// --------------------------------------------------------------------------- +// Deep match / merge +// --------------------------------------------------------------------------- +/** + * Deep-merge `match` into `target`, mutating `target` in place. + * Skips prototype-pollution keys. Used internally by {@link applySliceMatch}. + */ export const mergeMatch = (target: Record, match: Record): void => { for (const [key, matchValue] of Object.entries(match)) { if (key === "__proto__" || key === "constructor" || key === "prototype") { @@ -34,23 +69,37 @@ export const mergeMatch = (target: Record, match: Record, matchValue); - } - else { + } else { target[key] = { ...matchValue }; } - } - else { + } else { target[key] = matchValue; } } -} +}; -export const applySliceMatch = >(input: T, match: Record): T => { +/** + * Shallow-clone `input` then deep-merge the slice discriminator values from + * `match` on top, returning a complete slice element ready for insertion. + * + * @example + * applySliceMatch({ text: "hi" }, { coding: { code: "vital-signs", system: "…" } }) + * // → { text: "hi", coding: { code: "vital-signs", system: "…" } } + */ +export const applySliceMatch = (input: Partial, match: Partial>): T => { const result = { ...input } as Record; mergeMatch(result, match); return result as T; -} +}; +/** + * Recursively test whether `value` structurally contains everything in + * `match`. Arrays are matched with "every match item has a corresponding + * value item" semantics; objects are matched key-by-key; primitives use `===`. + * + * This is the core discriminator check used to identify which array element + * belongs to a given FHIR slice. + */ export const matchesValue = (value: unknown, match: unknown): boolean => { if (Array.isArray(match)) { if (!Array.isArray(value)) { @@ -70,41 +119,77 @@ export const matchesValue = (value: unknown, match: unknown): boolean => { return true; } return value === match; -} +}; -export const matchesSlice = (value: unknown, match: Record): boolean => { - return matchesValue(value, match); -} +// --------------------------------------------------------------------------- +// Extension helpers +// --------------------------------------------------------------------------- -export const extractComplexExtension = (extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, config: Array<{ name: string; valueField: string; isArray: boolean }>): Record | undefined => { +/** + * Read a complex (nested) FHIR extension into a plain key/value object. + * + * Each entry in `config` describes one sub-extension by URL, the name of its + * value field (e.g. `"valueString"`), and whether it may repeat. + * + * @returns A record keyed by sub-extension URL, or `undefined` if the + * extension has no nested children. + */ +export const extractComplexExtension = ( + extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, + config: Array<{ name: string; valueField: string; isArray: boolean }>, +): Record | undefined => { if (!extension?.extension) return undefined; const result: Record = {}; for (const { name, valueField, isArray } of config) { - const subExts = extension.extension.filter(e => e.url === name); + const subExts = extension.extension.filter((e) => e.url === name); if (isArray) { - result[name] = subExts.map(e => (e as Record)[valueField]); - } - else if (subExts[0]) { + result[name] = subExts.map((e) => (e as Record)[valueField]); + } else if (subExts[0]) { result[name] = (subExts[0] as Record)[valueField]; } } return result; -} +}; + +// --------------------------------------------------------------------------- +// Slice helpers +// --------------------------------------------------------------------------- -export const extractSliceSimplified = >(slice: T, matchKeys: string[]): Partial => { +/** + * Remove discriminator keys from a slice element, returning only the + * user-supplied portion. Used by slice getters so callers see a clean object + * without the fixed discriminator values baked in. + */ +export const stripMatchKeys = (slice: object, matchKeys: string[]): T => { const result = { ...slice } as Record; for (const key of matchKeys) { delete result[key]; } - return result as Partial; -} + return result as T; +}; -export const wrapSliceChoice = (input: Record, choiceVariant: string): Record => { - if (Object.keys(input).length === 0) return input; - return { [choiceVariant]: input }; -} +/** + * Wrap a flat input object under a choice-type key before inserting into a + * slice. For example, a Quantity value destined for `valueQuantity` is + * wrapped as `{ valueQuantity: { ...input } }`. + * + * No-op when `input` is empty (the slice will contain only discriminator + * defaults). + */ +export const wrapSliceChoice = (input: object, choiceVariant: string): Partial => { + if (Object.keys(input).length === 0) return input as Partial; + return { [choiceVariant]: input } as Partial; +}; -export const flattenSliceChoice = (slice: Record, matchKeys: string[], choiceVariant: string): Record => { +/** + * Inverse of {@link wrapSliceChoice}: strip discriminator keys, then hoist + * the value inside `choiceVariant` up to the top level. + * + * @example + * unwrapSliceChoice(raw, ["code"], "valueQuantity") + * // removes "code", moves raw.valueQuantity.* to top level + */ +export const unwrapSliceChoice = (slice: object, matchKeys: string[], choiceVariant: string): T => { const result = { ...slice } as Record; for (const key of matchKeys) { delete result[key]; @@ -114,58 +199,178 @@ export const flattenSliceChoice = (slice: Record, matchKeys: st if (isRecord(variantValue)) { Object.assign(result, variantValue); } - return result; -} + return result as T; +}; -export const validateRequired = (r: Record, field: string, path: string): string | undefined => { - return r[field] === undefined || r[field] === null ? `${path}: required field '${field}' is missing` : undefined; -} +/** + * Ensure that every required slice has at least a stub element in the array. + * Each `match` is a discriminator pattern; if no existing item satisfies it, + * a deep clone of the pattern is appended. + * + * Called in `createResource` so that required slices are always present even + * when the caller omits them. + */ +export const ensureSliceDefaults = (items: T[], ...matches: Record[]): T[] => { + for (const match of matches) { + if (!items.some((item) => matchesValue(item, match))) { + items.push(structuredClone(match) as T); + } + } + return items; +}; + +/** + * Add `canonicalUrl` to `resource.meta.profile` if not already present. + * Creates `meta` and `profile` when missing. + */ +export const ensureProfile = (resource: { meta?: { profile?: string[] } }, canonicalUrl: string): void => { + const meta = (resource.meta ??= {}); + const profiles = (meta.profile ??= []); + if (!profiles.includes(canonicalUrl)) profiles.push(canonicalUrl); +}; + +/** + * Find or insert a slice element in `list`. If an element matching `match` + * already exists it is replaced in place; otherwise `value` is appended. + */ +export const setArraySlice = (list: T[], match: Record, value: T): void => { + const index = list.findIndex((item) => matchesValue(item, match)); + if (index === -1) { + list.push(value); + } else { + list[index] = value; + } +}; -export const validateExcluded = (r: Record, field: string, path: string): string | undefined => { - return r[field] !== undefined ? `${path}: field '${field}' must not be present` : undefined; -} +/** Return the first element in `list` that satisfies the slice discriminator `match`. */ +export const getArraySlice = (list: readonly T[] | undefined, match: Record): T | undefined => { + if (!list) return undefined; + return list.find((item) => matchesValue(item, match)); +}; -export const validateFixedValue = (r: Record, field: string, expected: unknown, path: string): string | undefined => { - return matchesValue(r[field], expected) ? undefined : `${path}: field '${field}' does not match expected fixed value`; -} +// --------------------------------------------------------------------------- +// Validation helpers +// +// Each function returns an array of human-readable error strings (empty = ok). +// Profile classes spread them all into a single array from `validate()`. +// --------------------------------------------------------------------------- -export const validateSliceCardinality = (items: unknown[] | undefined, match: Record, sliceName: string, min: number, max: number, path: string): string[] => { - const count = (items ?? []).filter(item => matchesSlice(item, match)).length; +/** Checks that `field` is present (not `undefined` or `null`). */ +export const validateRequired = (res: Record, profileName: string, field: string): string[] => { + return res[field] === undefined || res[field] === null + ? [`${profileName}: required field '${field}' is missing`] + : []; +}; + +/** Checks that `field` is absent (profiles may exclude base fields). */ +export const validateExcluded = (res: Record, profileName: string, field: string): string[] => { + return res[field] !== undefined ? [`${profileName}: field '${field}' must not be present`] : []; +}; + +/** Checks that `field` structurally contains the expected fixed value. */ +export const validateFixedValue = ( + res: Record, + profileName: string, + field: string, + expected: unknown, +): string[] => { + return matchesValue(res[field], expected) + ? [] + : [`${profileName}: field '${field}' does not match expected fixed value`]; +}; + +/** + * Checks that the number of array elements matching `match` (a slice + * discriminator) falls within [`min`, `max`]. Pass `max = 0` for unbounded. + */ +export const validateSliceCardinality = ( + res: Record, + profileName: string, + field: string, + match: Record, + sliceName: string, + min: number, + max: number, +): string[] => { + const items = res[field] as unknown[] | undefined; + const count = (items ?? []).filter((item) => matchesValue(item, match)).length; const errors: string[] = []; if (count < min) { - errors.push(`${path}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`); + errors.push(`${profileName}.${field}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`); } if (max > 0 && count > max) { - errors.push(`${path}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`); + errors.push(`${profileName}.${field}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`); } return errors; -} +}; + +/** + * Checks that at least one of the listed choice-type variants is present. + * E.g. `["effectiveDateTime", "effectivePeriod"]`. + */ +export const validateChoiceRequired = ( + res: Record, + profileName: string, + choices: string[], +): string[] => { + return choices.some((c) => res[c] !== undefined) + ? [] + : [`${profileName}: at least one of ${choices.join(", ")} is required`]; +}; -export const validateEnum = (value: unknown, allowed: string[], field: string, path: string): string | undefined => { - if (value === undefined || value === null) return undefined; - if (typeof value === 'string') { - return allowed.includes(value) ? undefined : `${path}: field '${field}' value '${value}' is not in allowed values`; +/** + * Checks that the value of `field` has a code within `allowed`. + * Handles plain strings, Coding objects, and CodeableConcept objects. + * Skips validation when the field is absent. + */ +export const validateEnum = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; + if (typeof value === "string") { + return allowed.includes(value) + ? [] + : [`${profileName}: field '${field}' value '${value}' is not in allowed values`]; } const rec = value as Record; // Coding - if (typeof rec.code === 'string' && rec.system !== undefined) { - return allowed.includes(rec.code) ? undefined : `${path}: field '${field}' code '${rec.code}' is not in allowed values`; + if (typeof rec.code === "string" && rec.system !== undefined) { + return allowed.includes(rec.code) + ? [] + : [`${profileName}: field '${field}' code '${rec.code}' is not in allowed values`]; } // CodeableConcept if (Array.isArray(rec.coding)) { - const codes = (rec.coding as Array>).map(c => c.code as string).filter(Boolean); - const hasValid = codes.some(c => allowed.includes(c)); - return hasValid ? undefined : `${path}: field '${field}' has no coding with an allowed code`; + const codes = (rec.coding as Record[]).map((c) => c.code as string).filter(Boolean); + const hasValid = codes.some((c) => allowed.includes(c)); + return hasValid ? [] : [`${profileName}: field '${field}' has no coding with an allowed code`]; } - return undefined; -} + return []; +}; -export const validateReference = (value: unknown, allowed: string[], field: string, path: string): string | undefined => { - if (value === undefined || value === null) return undefined; +/** + * Checks that a Reference field points to one of the `allowed` resource + * types. Extracts the type from the `reference` string (the part before + * the first `/`). Skips validation when the field or reference is absent. + */ +export const validateReference = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; const ref = (value as Record).reference as string | undefined; - if (!ref) return undefined; - const slashIdx = ref.indexOf('/'); - if (slashIdx === -1) return undefined; + if (!ref) return []; + const slashIdx = ref.indexOf("/"); + if (slashIdx === -1) return []; const refType = ref.slice(0, slashIdx); - return allowed.includes(refType) ? undefined : `${path}: field '${field}' references '${refType}' but only ${allowed.join(', ')} are allowed`; -} + return allowed.includes(refType) + ? [] + : [`${profileName}: field '${field}' references '${refType}' but only ${allowed.join(", ")} are allowed`]; +}; diff --git a/examples/typescript-r4/profile-bp.test.ts b/examples/typescript-r4/profile-bp.test.ts index 78385e502..ad42f32c9 100644 --- a/examples/typescript-r4/profile-bp.test.ts +++ b/examples/typescript-r4/profile-bp.test.ts @@ -31,7 +31,7 @@ describe("blood pressure profile", () => { test("freshly created profile is not yet valid (missing effective)", () => { const errors = profile.validate(); - expect(errors).toEqual(["effective: at least one of effectiveDateTime, effectivePeriod is required"]); + expect(errors).toEqual(["observation-bp: at least one of effectiveDateTime, effectivePeriod is required"]); }); test("create() auto-populates component with systolic/diastolic stubs", () => { diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts index ad0e95fa4..8f87ef5a4 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts @@ -14,7 +14,7 @@ export interface observation_vitalsigns extends Observation { export type Observation_vitalsigns_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_vitalsignsProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -27,14 +27,13 @@ export type observation_vitalsignsProfileParams = { export class observation_vitalsignsProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/vitalsigns" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/vitalsigns")) profiles.push("http://hl7.org/fhir/StructureDefinition/vitalsigns") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/vitalsigns") } static from (resource: Observation) : observation_vitalsignsProfile { @@ -42,16 +41,18 @@ export class observation_vitalsignsProfile { } static createResource (args: observation_vitalsignsProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_vitalsignsProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", category: categoryWithDefaults, status: args.status, code: args.code, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/vitalsigns"] }, + meta: { profile: [observation_vitalsignsProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -64,6 +65,8 @@ export class observation_vitalsignsProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -122,52 +125,45 @@ export class observation_vitalsignsProfile { return this.resource as observation_vitalsigns } + // Slices and extensions + public setVSCat (input?: Observation_vitalsigns_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_vitalsignsProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): Observation_vitalsigns_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_vitalsignsProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_vitalsigns_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_vitalsignsProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-vitalsigns"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-vitalsigns.category")) - { const e = validateRequired(r, "code", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-vitalsigns"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-vitalsigns"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "observation-vitalsigns" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ] } } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts index 80bba2795..c9ab6c05f 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts @@ -21,11 +21,11 @@ export type USCoreBloodPressureProfile_Category_VSCatSliceInput = Omit & Quantity; export type USCoreBloodPressureProfile_Component_DiastolicSliceInput = Omit & Quantity; -import { applySliceMatch, matchesSlice, extractSliceSimplified, wrapSliceChoice, flattenSliceChoice, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, wrapSliceChoice, unwrapSliceChoice, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type USCoreBloodPressureProfileProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); - subject: Reference<"USCorePatientProfile">; + subject: Reference<"Patient">; category?: CodeableConcept<("social-history" | "vital-signs" | "imaging" | "laboratory" | "procedure" | "survey" | "exam" | "therapy" | "activity" | string)>[]; component?: ObservationComponent[]; } @@ -34,14 +34,15 @@ export type USCoreBloodPressureProfileProfileParams = { export class USCoreBloodPressureProfileProfile { static readonly canonicalUrl = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private static readonly systolicSliceMatch: Record = {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}} + private static readonly diastolicSliceMatch: Record = {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure")) profiles.push("http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure") + ensureProfile(resource, "http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure") } static from (resource: Observation) : USCoreBloodPressureProfileProfile { @@ -49,21 +50,24 @@ export class USCoreBloodPressureProfileProfile { } static createResource (args: USCoreBloodPressureProfileProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const componentDefaults = [{"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}},{"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}}] as unknown[] - const componentWithDefaults = [...(args.component ?? [])] as unknown[] - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}} as Record))) componentWithDefaults.push(componentDefaults[0]!) - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}} as Record))) componentWithDefaults.push(componentDefaults[1]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + USCoreBloodPressureProfileProfile.VSCatSliceMatch, + ) + const componentWithDefaults = ensureSliceDefaults( + [...(args.component ?? [])], + USCoreBloodPressureProfileProfile.systolicSliceMatch, + USCoreBloodPressureProfileProfile.diastolicSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"system":"http://loinc.org","code":"85354-9"}]}, category: categoryWithDefaults, component: componentWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure"] }, + meta: { profile: [USCoreBloodPressureProfileProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -76,6 +80,8 @@ export class USCoreBloodPressureProfileProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -85,11 +91,11 @@ export class USCoreBloodPressureProfileProfile { return this } - getSubject () : Reference<"USCorePatientProfile"> | undefined { - return this.resource.subject as Reference<"USCorePatientProfile"> | undefined + getSubject () : Reference<"Patient"> | undefined { + return this.resource.subject as Reference<"Patient"> | undefined } - setSubject (value: Reference<"USCorePatientProfile">) : this { + setSubject (value: Reference<"Patient">) : this { Object.assign(this.resource, { subject: value }) return this } @@ -242,116 +248,91 @@ export class USCoreBloodPressureProfileProfile { return this.resource as USCoreBloodPressureProfile } + // Slices and extensions + public setVSCat (input?: USCoreBloodPressureProfile_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = USCoreBloodPressureProfileProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public setSystolic (input?: USCoreBloodPressureProfile_Component_SystolicSliceInput): this { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}} as Record - const value = applySliceMatch(wrapSliceChoice((input ?? {}) as Record, "valueQuantity"), match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = USCoreBloodPressureProfileProfile.systolicSliceMatch + const wrapped = wrapSliceChoice(input ?? {}, "valueQuantity") + const value = applySliceMatch(wrapped, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public setDiastolic (input?: USCoreBloodPressureProfile_Component_DiastolicSliceInput): this { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}} as Record - const value = applySliceMatch(wrapSliceChoice((input ?? {}) as Record, "valueQuantity"), match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = USCoreBloodPressureProfileProfile.diastolicSliceMatch + const wrapped = wrapSliceChoice(input ?? {}, "valueQuantity") + const value = applySliceMatch(wrapped, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public getVSCat (): USCoreBloodPressureProfile_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as USCoreBloodPressureProfile_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } public getSystolic (): USCoreBloodPressureProfile_Component_SystolicSliceInput | undefined { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.systolicSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return flattenSliceChoice(item as unknown as Record, ["code"], "valueQuantity") as USCoreBloodPressureProfile_Component_SystolicSliceInput + return unwrapSliceChoice(item, ["code"], "valueQuantity") } public getSystolicRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.systolicSliceMatch + const item = getArraySlice(this.resource.component, match) return item } public getDiastolic (): USCoreBloodPressureProfile_Component_DiastolicSliceInput | undefined { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.diastolicSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return flattenSliceChoice(item as unknown as Record, ["code"], "valueQuantity") as USCoreBloodPressureProfile_Component_DiastolicSliceInput + return unwrapSliceChoice(item, ["code"], "valueQuantity") } public getDiastolicRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.diastolicSliceMatch + const item = getArraySlice(this.resource.component, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "USCoreBloodPressureProfile.category")) - { const e = validateRequired(r, "code", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"system":"http://loinc.org","code":"85354-9"}]}, "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["USCorePatientProfile"], "subject", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}}, "systolic", 1, 1, "USCoreBloodPressureProfile.component")) - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}}, "diastolic", 1, 1, "USCoreBloodPressureProfile.component")) - { const e = validateReference(r["performer"], ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","USCorePatientProfile","USCorePractitionerProfile","USCoreRelatedPersonProfile"], "performer", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "USCoreBloodPressureProfile" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"system":"http://loinc.org","code":"85354-9"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}}, "systolic", 1, 1), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}}, "diastolic", 1, 1), + ...validateReference(res, profileName, "performer", ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","Patient","USCorePractitionerProfile","USCoreRelatedPersonProfile"]), + ] } } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts index b3d2d42ae..55d337013 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts @@ -18,11 +18,11 @@ export interface USCoreBodyWeightProfile extends Observation { export type USCoreBodyWeightProfile_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type USCoreBodyWeightProfileProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); - subject: Reference<"USCorePatientProfile">; + subject: Reference<"Patient">; category?: CodeableConcept<("social-history" | "vital-signs" | "imaging" | "laboratory" | "procedure" | "survey" | "exam" | "therapy" | "activity" | string)>[]; } @@ -30,14 +30,13 @@ export type USCoreBodyWeightProfileProfileParams = { export class USCoreBodyWeightProfileProfile { static readonly canonicalUrl = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight")) profiles.push("http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight") + ensureProfile(resource, "http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight") } static from (resource: Observation) : USCoreBodyWeightProfileProfile { @@ -45,16 +44,18 @@ export class USCoreBodyWeightProfileProfile { } static createResource (args: USCoreBodyWeightProfileProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + USCoreBodyWeightProfileProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"system":"http://loinc.org","code":"29463-7"}]}, category: categoryWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight"] }, + meta: { profile: [USCoreBodyWeightProfileProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -67,6 +68,8 @@ export class USCoreBodyWeightProfileProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -76,11 +79,11 @@ export class USCoreBodyWeightProfileProfile { return this } - getSubject () : Reference<"USCorePatientProfile"> | undefined { - return this.resource.subject as Reference<"USCorePatientProfile"> | undefined + getSubject () : Reference<"Patient"> | undefined { + return this.resource.subject as Reference<"Patient"> | undefined } - setSubject (value: Reference<"USCorePatientProfile">) : this { + setSubject (value: Reference<"Patient">) : this { Object.assign(this.resource, { subject: value }) return this } @@ -224,54 +227,47 @@ export class USCoreBodyWeightProfileProfile { return this.resource as USCoreBodyWeightProfile } + // Slices and extensions + public setVSCat (input?: USCoreBodyWeightProfile_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = USCoreBodyWeightProfileProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): USCoreBodyWeightProfile_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBodyWeightProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as USCoreBodyWeightProfile_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBodyWeightProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "USCoreBodyWeightProfile.category")) - { const e = validateRequired(r, "code", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"system":"http://loinc.org","code":"29463-7"}]}, "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["USCorePatientProfile"], "subject", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateReference(r["performer"], ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","USCorePatientProfile","USCorePractitionerProfile","USCoreRelatedPersonProfile"], "performer", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "USCoreBodyWeightProfile" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"system":"http://loinc.org","code":"29463-7"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "performer", ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","Patient","USCorePractitionerProfile","USCoreRelatedPersonProfile"]), + ] } } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreVitalSignsProfile.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreVitalSignsProfile.ts index 12d7b44ad..af9c2dffe 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreVitalSignsProfile.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreVitalSignsProfile.ts @@ -18,12 +18,12 @@ export interface USCoreVitalSignsProfile extends Observation { export type USCoreVitalSignsProfile_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type USCoreVitalSignsProfileProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); code: CodeableConcept<("2708-6" | "29463-7" | "3140-1" | "3150-0" | "3151-8" | "39156-5" | "59408-5" | "59575-1" | "59576-9" | "77606-2" | "8287-5" | "8289-1" | "8302-2" | "8306-3" | "8310-5" | "8462-4" | "8478-0" | "8480-6" | "8867-4" | "9279-1" | "9843-4" | string)>; - subject: Reference<"USCorePatientProfile">; + subject: Reference<"Patient">; category?: CodeableConcept<("social-history" | "vital-signs" | "imaging" | "laboratory" | "procedure" | "survey" | "exam" | "therapy" | "activity" | string)>[]; } @@ -31,14 +31,13 @@ export type USCoreVitalSignsProfileProfileParams = { export class USCoreVitalSignsProfileProfile { static readonly canonicalUrl = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs")) profiles.push("http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs") + ensureProfile(resource, "http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs") } static from (resource: Observation) : USCoreVitalSignsProfileProfile { @@ -46,16 +45,18 @@ export class USCoreVitalSignsProfileProfile { } static createResource (args: USCoreVitalSignsProfileProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + USCoreVitalSignsProfileProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", category: categoryWithDefaults, status: args.status, code: args.code, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs"] }, + meta: { profile: [USCoreVitalSignsProfileProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -68,6 +69,8 @@ export class USCoreVitalSignsProfileProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -86,11 +89,11 @@ export class USCoreVitalSignsProfileProfile { return this } - getSubject () : Reference<"USCorePatientProfile"> | undefined { - return this.resource.subject as Reference<"USCorePatientProfile"> | undefined + getSubject () : Reference<"Patient"> | undefined { + return this.resource.subject as Reference<"Patient"> | undefined } - setSubject (value: Reference<"USCorePatientProfile">) : this { + setSubject (value: Reference<"Patient">) : this { Object.assign(this.resource, { subject: value }) return this } @@ -225,53 +228,46 @@ export class USCoreVitalSignsProfileProfile { return this.resource as USCoreVitalSignsProfile } + // Slices and extensions + public setVSCat (input?: USCoreVitalSignsProfile_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = USCoreVitalSignsProfileProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): USCoreVitalSignsProfile_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreVitalSignsProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as USCoreVitalSignsProfile_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreVitalSignsProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "USCoreVitalSignsProfile.category")) - { const e = validateRequired(r, "code", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["USCorePatientProfile"], "subject", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateReference(r["performer"], ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","USCorePatientProfile","USCorePractitionerProfile","USCoreRelatedPersonProfile"], "performer", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "USCoreVitalSignsProfile" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "performer", ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","Patient","USCorePractitionerProfile","USCoreRelatedPersonProfile"]), + ] } } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Patient_USCorePatientProfile.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Patient_USCorePatientProfile.ts index 133fa3930..6a505f26f 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Patient_USCorePatientProfile.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Patient_USCorePatientProfile.ts @@ -31,7 +31,7 @@ export type USCorePatientProfile_TribalAffiliationInput = { isEnrolled?: boolean; } -import { extractComplexExtension, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, extractComplexExtension, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type USCorePatientProfileProfileParams = { identifier: Identifier[]; @@ -46,10 +46,7 @@ export class USCorePatientProfileProfile { constructor (resource: Patient) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient")) profiles.push("http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient") + ensureProfile(resource, "http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient") } static from (resource: Patient) : USCorePatientProfileProfile { @@ -57,11 +54,11 @@ export class USCorePatientProfileProfile { } static createResource (args: USCorePatientProfileProfileParams) : Patient { - const resource: Patient = { + const resource = { resourceType: "Patient", identifier: args.identifier, name: args.name, - meta: { profile: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient"] }, + meta: { profile: [USCorePatientProfileProfile.canonicalUrl] }, } as unknown as Patient return resource } @@ -74,6 +71,8 @@ export class USCorePatientProfileProfile { return this.resource } + // Field accessors + getIdentifier () : Identifier[] | undefined { return this.resource.identifier as Identifier[] | undefined } @@ -96,6 +95,8 @@ export class USCorePatientProfileProfile { return this.resource as USCorePatientProfile } + // Slices and extensions + public setRace (input: USCorePatientProfile_RaceInput): this { const subExtensions: Extension[] = [] if (input.ombCategory !== undefined) { @@ -213,12 +214,15 @@ export class USCorePatientProfileProfile { return ext } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "identifier", "USCorePatientProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "name", "USCorePatientProfile"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "USCorePatientProfile" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "identifier"), + ...validateRequired(res, profileName, "name"), + ] } } diff --git a/examples/typescript-us-core/fhir-types/profile-helpers.ts b/examples/typescript-us-core/fhir-types/profile-helpers.ts index ab04e6bd0..4f45db45b 100644 --- a/examples/typescript-us-core/fhir-types/profile-helpers.ts +++ b/examples/typescript-us-core/fhir-types/profile-helpers.ts @@ -1,12 +1,40 @@ -// WARNING: This file is autogenerated by @atomic-ehr/codegen. -// GitHub: https://github.com/atomic-ehr/codegen -// Any manual changes made to this file may be overwritten. +/** + * Runtime helpers for generated FHIR profile classes. + * + * This file is copied verbatim into every generated TypeScript output and + * imported by profile modules. It provides: + * + * - **Slice helpers** – match, get, set, and default-fill array slices + * defined by a FHIR StructureDefinition. + * - **Extension helpers** – read complex (nested) FHIR extensions into + * plain objects. + * - **Choice-type helpers** – wrap/unwrap polymorphic `value[x]` fields so + * profile classes can expose a flat API. + * - **Validation helpers** – lightweight structural checks that profile + * classes call from their `validate()` method. + * - **Misc utilities** – deep-match, deep-merge, path navigation. + */ +// --------------------------------------------------------------------------- +// General utilities +// --------------------------------------------------------------------------- + +/** Type guard: `value` is a non-null, non-array plain object. */ export const isRecord = (value: unknown): value is Record => { return value !== null && typeof value === "object" && !Array.isArray(value); -} +}; -export const getOrCreateObjectAtPath = (root: Record, path: string[]): Record => { +/** + * Walk `path` segments from `root`, creating intermediate objects (or using + * the first element of an existing array) as needed. Returns the leaf object. + * + * Used by extension setters to reach a nested target inside a resource. + * + * @example + * ensurePath(resource, ["contact", "telecom"]) + * // → resource.contact.telecom (created if absent) + */ +export const ensurePath = (root: Record, path: string[]): Record => { let current: Record = root; for (const segment of path) { if (Array.isArray(current[segment])) { @@ -15,8 +43,7 @@ export const getOrCreateObjectAtPath = (root: Record, path: str list.push({}); } current = list[0] as Record; - } - else { + } else { if (!isRecord(current[segment])) { current[segment] = {}; } @@ -24,8 +51,16 @@ export const getOrCreateObjectAtPath = (root: Record, path: str } } return current; -} +}; + +// --------------------------------------------------------------------------- +// Deep match / merge +// --------------------------------------------------------------------------- +/** + * Deep-merge `match` into `target`, mutating `target` in place. + * Skips prototype-pollution keys. Used internally by {@link applySliceMatch}. + */ export const mergeMatch = (target: Record, match: Record): void => { for (const [key, matchValue] of Object.entries(match)) { if (key === "__proto__" || key === "constructor" || key === "prototype") { @@ -34,23 +69,37 @@ export const mergeMatch = (target: Record, match: Record, matchValue); - } - else { + } else { target[key] = { ...matchValue }; } - } - else { + } else { target[key] = matchValue; } } -} +}; -export const applySliceMatch = >(input: T, match: Record): T => { +/** + * Shallow-clone `input` then deep-merge the slice discriminator values from + * `match` on top, returning a complete slice element ready for insertion. + * + * @example + * applySliceMatch({ text: "hi" }, { coding: { code: "vital-signs", system: "…" } }) + * // → { text: "hi", coding: { code: "vital-signs", system: "…" } } + */ +export const applySliceMatch = (input: Partial, match: Partial>): T => { const result = { ...input } as Record; mergeMatch(result, match); return result as T; -} +}; +/** + * Recursively test whether `value` structurally contains everything in + * `match`. Arrays are matched with "every match item has a corresponding + * value item" semantics; objects are matched key-by-key; primitives use `===`. + * + * This is the core discriminator check used to identify which array element + * belongs to a given FHIR slice. + */ export const matchesValue = (value: unknown, match: unknown): boolean => { if (Array.isArray(match)) { if (!Array.isArray(value)) { @@ -70,41 +119,77 @@ export const matchesValue = (value: unknown, match: unknown): boolean => { return true; } return value === match; -} +}; -export const matchesSlice = (value: unknown, match: Record): boolean => { - return matchesValue(value, match); -} +// --------------------------------------------------------------------------- +// Extension helpers +// --------------------------------------------------------------------------- -export const extractComplexExtension = (extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, config: Array<{ name: string; valueField: string; isArray: boolean }>): Record | undefined => { +/** + * Read a complex (nested) FHIR extension into a plain key/value object. + * + * Each entry in `config` describes one sub-extension by URL, the name of its + * value field (e.g. `"valueString"`), and whether it may repeat. + * + * @returns A record keyed by sub-extension URL, or `undefined` if the + * extension has no nested children. + */ +export const extractComplexExtension = ( + extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, + config: Array<{ name: string; valueField: string; isArray: boolean }>, +): Record | undefined => { if (!extension?.extension) return undefined; const result: Record = {}; for (const { name, valueField, isArray } of config) { - const subExts = extension.extension.filter(e => e.url === name); + const subExts = extension.extension.filter((e) => e.url === name); if (isArray) { - result[name] = subExts.map(e => (e as Record)[valueField]); - } - else if (subExts[0]) { + result[name] = subExts.map((e) => (e as Record)[valueField]); + } else if (subExts[0]) { result[name] = (subExts[0] as Record)[valueField]; } } return result; -} +}; + +// --------------------------------------------------------------------------- +// Slice helpers +// --------------------------------------------------------------------------- -export const extractSliceSimplified = >(slice: T, matchKeys: string[]): Partial => { +/** + * Remove discriminator keys from a slice element, returning only the + * user-supplied portion. Used by slice getters so callers see a clean object + * without the fixed discriminator values baked in. + */ +export const stripMatchKeys = (slice: object, matchKeys: string[]): T => { const result = { ...slice } as Record; for (const key of matchKeys) { delete result[key]; } - return result as Partial; -} + return result as T; +}; -export const wrapSliceChoice = (input: Record, choiceVariant: string): Record => { - if (Object.keys(input).length === 0) return input; - return { [choiceVariant]: input }; -} +/** + * Wrap a flat input object under a choice-type key before inserting into a + * slice. For example, a Quantity value destined for `valueQuantity` is + * wrapped as `{ valueQuantity: { ...input } }`. + * + * No-op when `input` is empty (the slice will contain only discriminator + * defaults). + */ +export const wrapSliceChoice = (input: object, choiceVariant: string): Partial => { + if (Object.keys(input).length === 0) return input as Partial; + return { [choiceVariant]: input } as Partial; +}; -export const flattenSliceChoice = (slice: Record, matchKeys: string[], choiceVariant: string): Record => { +/** + * Inverse of {@link wrapSliceChoice}: strip discriminator keys, then hoist + * the value inside `choiceVariant` up to the top level. + * + * @example + * unwrapSliceChoice(raw, ["code"], "valueQuantity") + * // removes "code", moves raw.valueQuantity.* to top level + */ +export const unwrapSliceChoice = (slice: object, matchKeys: string[], choiceVariant: string): T => { const result = { ...slice } as Record; for (const key of matchKeys) { delete result[key]; @@ -114,58 +199,178 @@ export const flattenSliceChoice = (slice: Record, matchKeys: st if (isRecord(variantValue)) { Object.assign(result, variantValue); } - return result; -} + return result as T; +}; -export const validateRequired = (r: Record, field: string, path: string): string | undefined => { - return r[field] === undefined || r[field] === null ? `${path}: required field '${field}' is missing` : undefined; -} +/** + * Ensure that every required slice has at least a stub element in the array. + * Each `match` is a discriminator pattern; if no existing item satisfies it, + * a deep clone of the pattern is appended. + * + * Called in `createResource` so that required slices are always present even + * when the caller omits them. + */ +export const ensureSliceDefaults = (items: T[], ...matches: Record[]): T[] => { + for (const match of matches) { + if (!items.some((item) => matchesValue(item, match))) { + items.push(structuredClone(match) as T); + } + } + return items; +}; + +/** + * Add `canonicalUrl` to `resource.meta.profile` if not already present. + * Creates `meta` and `profile` when missing. + */ +export const ensureProfile = (resource: { meta?: { profile?: string[] } }, canonicalUrl: string): void => { + const meta = (resource.meta ??= {}); + const profiles = (meta.profile ??= []); + if (!profiles.includes(canonicalUrl)) profiles.push(canonicalUrl); +}; + +/** + * Find or insert a slice element in `list`. If an element matching `match` + * already exists it is replaced in place; otherwise `value` is appended. + */ +export const setArraySlice = (list: T[], match: Record, value: T): void => { + const index = list.findIndex((item) => matchesValue(item, match)); + if (index === -1) { + list.push(value); + } else { + list[index] = value; + } +}; -export const validateExcluded = (r: Record, field: string, path: string): string | undefined => { - return r[field] !== undefined ? `${path}: field '${field}' must not be present` : undefined; -} +/** Return the first element in `list` that satisfies the slice discriminator `match`. */ +export const getArraySlice = (list: readonly T[] | undefined, match: Record): T | undefined => { + if (!list) return undefined; + return list.find((item) => matchesValue(item, match)); +}; -export const validateFixedValue = (r: Record, field: string, expected: unknown, path: string): string | undefined => { - return matchesValue(r[field], expected) ? undefined : `${path}: field '${field}' does not match expected fixed value`; -} +// --------------------------------------------------------------------------- +// Validation helpers +// +// Each function returns an array of human-readable error strings (empty = ok). +// Profile classes spread them all into a single array from `validate()`. +// --------------------------------------------------------------------------- -export const validateSliceCardinality = (items: unknown[] | undefined, match: Record, sliceName: string, min: number, max: number, path: string): string[] => { - const count = (items ?? []).filter(item => matchesSlice(item, match)).length; +/** Checks that `field` is present (not `undefined` or `null`). */ +export const validateRequired = (res: Record, profileName: string, field: string): string[] => { + return res[field] === undefined || res[field] === null + ? [`${profileName}: required field '${field}' is missing`] + : []; +}; + +/** Checks that `field` is absent (profiles may exclude base fields). */ +export const validateExcluded = (res: Record, profileName: string, field: string): string[] => { + return res[field] !== undefined ? [`${profileName}: field '${field}' must not be present`] : []; +}; + +/** Checks that `field` structurally contains the expected fixed value. */ +export const validateFixedValue = ( + res: Record, + profileName: string, + field: string, + expected: unknown, +): string[] => { + return matchesValue(res[field], expected) + ? [] + : [`${profileName}: field '${field}' does not match expected fixed value`]; +}; + +/** + * Checks that the number of array elements matching `match` (a slice + * discriminator) falls within [`min`, `max`]. Pass `max = 0` for unbounded. + */ +export const validateSliceCardinality = ( + res: Record, + profileName: string, + field: string, + match: Record, + sliceName: string, + min: number, + max: number, +): string[] => { + const items = res[field] as unknown[] | undefined; + const count = (items ?? []).filter((item) => matchesValue(item, match)).length; const errors: string[] = []; if (count < min) { - errors.push(`${path}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`); + errors.push(`${profileName}.${field}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`); } if (max > 0 && count > max) { - errors.push(`${path}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`); + errors.push(`${profileName}.${field}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`); } return errors; -} +}; + +/** + * Checks that at least one of the listed choice-type variants is present. + * E.g. `["effectiveDateTime", "effectivePeriod"]`. + */ +export const validateChoiceRequired = ( + res: Record, + profileName: string, + choices: string[], +): string[] => { + return choices.some((c) => res[c] !== undefined) + ? [] + : [`${profileName}: at least one of ${choices.join(", ")} is required`]; +}; -export const validateEnum = (value: unknown, allowed: string[], field: string, path: string): string | undefined => { - if (value === undefined || value === null) return undefined; - if (typeof value === 'string') { - return allowed.includes(value) ? undefined : `${path}: field '${field}' value '${value}' is not in allowed values`; +/** + * Checks that the value of `field` has a code within `allowed`. + * Handles plain strings, Coding objects, and CodeableConcept objects. + * Skips validation when the field is absent. + */ +export const validateEnum = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; + if (typeof value === "string") { + return allowed.includes(value) + ? [] + : [`${profileName}: field '${field}' value '${value}' is not in allowed values`]; } const rec = value as Record; // Coding - if (typeof rec.code === 'string' && rec.system !== undefined) { - return allowed.includes(rec.code) ? undefined : `${path}: field '${field}' code '${rec.code}' is not in allowed values`; + if (typeof rec.code === "string" && rec.system !== undefined) { + return allowed.includes(rec.code) + ? [] + : [`${profileName}: field '${field}' code '${rec.code}' is not in allowed values`]; } // CodeableConcept if (Array.isArray(rec.coding)) { - const codes = (rec.coding as Array>).map(c => c.code as string).filter(Boolean); - const hasValid = codes.some(c => allowed.includes(c)); - return hasValid ? undefined : `${path}: field '${field}' has no coding with an allowed code`; + const codes = (rec.coding as Record[]).map((c) => c.code as string).filter(Boolean); + const hasValid = codes.some((c) => allowed.includes(c)); + return hasValid ? [] : [`${profileName}: field '${field}' has no coding with an allowed code`]; } - return undefined; -} + return []; +}; -export const validateReference = (value: unknown, allowed: string[], field: string, path: string): string | undefined => { - if (value === undefined || value === null) return undefined; +/** + * Checks that a Reference field points to one of the `allowed` resource + * types. Extracts the type from the `reference` string (the part before + * the first `/`). Skips validation when the field or reference is absent. + */ +export const validateReference = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; const ref = (value as Record).reference as string | undefined; - if (!ref) return undefined; - const slashIdx = ref.indexOf('/'); - if (slashIdx === -1) return undefined; + if (!ref) return []; + const slashIdx = ref.indexOf("/"); + if (slashIdx === -1) return []; const refType = ref.slice(0, slashIdx); - return allowed.includes(refType) ? undefined : `${path}: field '${field}' references '${refType}' but only ${allowed.join(', ')} are allowed`; -} + return allowed.includes(refType) + ? [] + : [`${profileName}: field '${field}' references '${refType}' but only ${allowed.join(", ")} are allowed`]; +}; From 3a7ef7e325189ac0e1dbb3b8404a6dbfc6f667c5 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 6 Mar 2026 13:32:16 +0100 Subject: [PATCH 4/6] Clean up README: update TOC, feature table, inline footnote, fix headings --- README.md | 53 ++++++++++++++++++++++++++--------------------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 48119847b..69520613e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - [Atomic EHR Codegen](#atomic-ehr-codegen) - [Features](#features) + - [Guides](#guides) - [Versions & Release Cycle](#versions--release-cycle) - [Installation](#installation) - [Quick Start](#quick-start) @@ -20,31 +21,24 @@ - [Intermediate - Type Schema](#intermediate---type-schema) - [Tree Shaking](#tree-shaking) - [Field-Level Tree Shaking](#field-level-tree-shaking) + - [Logical Model Promotion](#logical-model-promotion) - [Generation](#generation) - [1. Writer-Based Generation (Programmatic)](#1-writer-based-generation-programmatic) - [2. Mustache Template-Based Generation (Declarative)](#2-mustache-template-based-generation-declarative) + - [Profile Classes](#profile-classes) - [Support](#support) -- [Footnotes](#footnotes) A powerful, extensible code generation toolkit for FHIR ([Fast Healthcare Interoperability Resources](https://www.hl7.org/fhir/)) that transforms FHIR specifications into strongly-typed code for multiple programming languages. -Guides: - -- **[Writer Generator Guide](docs/guides/writer-generator.md)** - Build custom code generators with the Writer base class -- **[Mustache Generator Guide](docs/guides/mustache-generator.md)** - Template-based code generation for any language -- **[TypeSchemaIndex Guide](docs/guides/typeschema-index.md)** - Type Schema structure and utilities -- **[Testing Generators Guide](docs/guides/testing-generators.md)** - Unit tests, snapshot testing, and best practices -- **[Contributing Guide](CONTRIBUTING.md)** - Development setup and workflow - ## Features - [x] **Multi-Package Support** — Load packages from the [FHIR registry](examples/typescript-r4/), [remote TGZ files](examples/typescript-sql-on-fhir/), or a [local folder with custom StructureDefinitions](examples/local-package-folder/) - Tested with hl7.fhir.r4.core, US Core, C-CDA, SQL on FHIR, etc. - [x] **Resources & Complex Types** — Generates typed definitions with proper inheritance - [x] **Value Set Bindings** — Strongly-typed enums from FHIR terminology bindings -- [x] **Profiles & Extensions** — Factory methods with auto-populated fixed values and required slices ([R4 profiles](examples/typescript-r4/profile-bp.test.ts), [US Core](examples/typescript-us-core/)) +- [x] **Profiles** — Factory methods with auto-populated fixed values and required slices ([R4 profiles](examples/typescript-r4/profile-bp.test.ts), [US Core](examples/typescript-us-core/)) - Extensions — flat typed accessors (e.g. `setRace()` on US Core Patient), [standalone extension profiles](examples/typescript-r4/extension-profile.test.ts) - Slicing — typed get/set accessors with discriminator matching - Validation — runtime `validate()` for required fields, fixed values, slice cardinality, enums, references @@ -52,20 +46,27 @@ Guides: - TypeSchema is a universal intermediate representation — add a new language by writing only the final generation stage - Built-in generators: TypeScript, Python/Pydantic, C#, and Mustache templates - [x] **TypeSchema Transformations**: - - [x] Tree Shaking — include only the resources and fields you need; automatically resolves dependencies - - [x] Logical Model Promotion — promote FHIR logical models (e.g. CDA ClinicalDocument) to first-class resources - - [ ] Renaming — custom naming conventions for generated types, fields, packages, etc. + - [x] **Tree Shaking** — include only the resources and fields you need; automatically resolves dependencies + - [x] **Logical Model Promotion** — promote FHIR logical models to first-class resources + - [ ] Renaming — custom naming conventions for generated types and fields - [ ] **Search Builders** — type-safe FHIR search query construction - [ ] **Operation Generation** — type-safe FHIR operation calls -| Feature | TypeSchema | TypeScript | Python | C# | Mustache | -|---|---|---|---|---|---| -| Resources & Complex Types | yes | yes | yes | yes | template | -| Value Set Bindings | yes | inline | inline | enum | template | -| Profiles & Extensions | yes | yes | no | no | no | -| Tree Shaking | yes | 〃 | 〃 | 〃 | 〃 | -| Logical Model Promotion | yes | 〃 | 〃 | 〃 | 〃 | +| Feature | TypeScript | Python | C# | Mustache | +|---------------------------|------------|---------|------|----------| +| Resources & Complex Types | yes | yes | yes | template | +| Value Set Bindings | inline | limited | enum | template | +| Primitive Extensions | yes | no | no | no | +| Profiles | yes | no | no | no | +| Profile Validation | yes | no | no | no | +## Guides + +- **[Writer Generator Guide](docs/guides/writer-generator.md)** - Build custom code generators with the Writer base class +- **[Mustache Generator Guide](docs/guides/mustache-generator.md)** - Template-based code generation for any language +- **[TypeSchemaIndex Guide](docs/guides/typeschema-index.md)** - Type Schema structure and utilities +- **[Testing Generators Guide](docs/guides/testing-generators.md)** - Unit tests, snapshot testing, and best practices +- **[Contributing Guide](CONTRIBUTING.md)** - Development setup and workflow ## Versions & Release Cycle @@ -204,7 +205,7 @@ Use the new `localPackage` helper to point the builder at an on-disk FHIR packag .localTgzPackage("./packages/my-custom-ig.tgz") ``` -The example above points Canonical Manager at `./custom-profiles`, installs the HL7 R4 core dependency automatically, and then limits generation to the custom `ExampleNotebook` logical model plus the standard R4 `Patient` resource via tree shaking. The `localTgzPackage` helper registers `.tgz` artifacts that Canonical Manager already knows how to unpack. +The example above points Canonical Manager at `./custom-profiles` and installs the HL7 R4 core dependency automatically. The `localTgzPackage` helper registers `.tgz` artifacts that Canonical Manager already knows how to unpack. ### Intermediate - Type Schema @@ -263,7 +264,7 @@ Beyond resource-level filtering, tree shaking supports fine-grained field select FHIR choice types (like `multipleBirth[x]` which can be boolean or integer) are handled intelligently. Selecting/ignoring the base field affects all variants, while targeting specific variants only affects those types. -##### Logical Promotion +#### Logical Model Promotion Some implementation guides expose logical models (logical-kind StructureDefinitions) that are intended to be used like resources in generated SDKs. The code generator supports promoting selected logical models to behave as resources during generation. @@ -291,9 +292,7 @@ For languages with built-in support (TypeScript, Python, C#), extend the `Writer - **FileSystemWriter**: Base class providing file I/O, directory management, and buffer handling (both disk and in-memory modes) - **Writer**: Extends FileSystemWriter with code formatting utilities (indentation, blocks, comments, line management) -- **Language Writers** (`TypeScript`, `Python`[^py], `CSharp`): Implement language-specific generation logic by traversing TypeSchema index and generating corresponding types, interfaces, or classes - -[^py]: For details on [Type Schema: Python SDK for FHIR](https://www.health-samurai.io/articles/type-schema-python-sdk-for-fhir) +- **Language Writers** (`TypeScript`, `Python`, `CSharp`): Implement language-specific generation logic by traversing TypeSchema index and generating corresponding types, interfaces, or classes (see also: [Type Schema: Python SDK for FHIR](https://www.health-samurai.io/articles/type-schema-python-sdk-for-fhir)) Each language writer maintains full control over output formatting while leveraging high-level abstractions for common code patterns. Writers follow language idioms and best practices, with optimized output for production use. @@ -373,8 +372,8 @@ See [examples/typescript-r4/](examples/typescript-r4/) for R4 profile tests and ## Support -- 🐛 [Issue Tracker](https://github.com/atomic-ehr/codegen/issues) +- [Issue Tracker](https://github.com/atomic-ehr/codegen/issues) --- -Built with ❤️ by the Atomic Healthcare team +Built by the Atomic Healthcare team From 1a58a26411ba31be1d2cbe09957391c1965ef701 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 6 Mar 2026 13:32:20 +0100 Subject: [PATCH 5/6] Update CLAUDE.md with PR style and commit guidelines --- CLAUDE.md | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2eccb3a58..5bfce572f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,14 @@ FHIR Package → TypeSchema Generator → TypeSchema Format → Code Generators - If a change is a direct fix for a specific previous commit, place it immediately after that commit with a `fix:` prefix in the message. - Typical commit order: source changes → test changes → regenerated examples. Example updates should be the last commit in the branch. +## Pull Request Style + +- PR body should be a bullet list summarizing changes — no section headers, no test plan. + - Use two-level nesting to group related items when the list is long; keep it flat when short. +- Keep bullets concise and focused on what changed, not why. +- When a PR changes generated code or user-facing API, include before/after code examples. + - Add a short motivation line before each example explaining why the change was made. + ## Development Guidelines ### TypeScript Configuration @@ -198,6 +206,29 @@ Located in `src/api/writer-generator/`: - Each writer traverses TypeSchema index and generates code - Maintains language-specific idioms and conventions +## Static Assets for Generators + +Static files that are copied verbatim into generated output live in `assets/api/writer-generator//`. Each language writer has a resolver function (e.g., `resolveTsAssets`, `resolvePyAssets`) that handles path resolution for both dev (`src/`) and dist (`dist/`) builds. + +**Pattern:** +``` +assets/api/writer-generator/ +├── typescript/profile-helpers.ts # Runtime helpers for TS profile classes +└── python/ + ├── requirements.txt + ├── fhirpy_base_model.py + └── resource_family_validator.py +``` + +**How it works:** +1. Asset files are authored/maintained directly in `assets/` (included in biome linting) +2. Writers copy them to output via `this.cp("filename", "filename")` — uses `Writer.cp()` which resolves via `resolveAssets` +3. Each language writer sets `resolveAssets` in its constructor (e.g., TypeScript writer defaults to `resolveTsAssets`) + +**When to use assets vs programmatic generation:** +- Use assets for static runtime code shared across all generated profiles (helpers, validators, base models) +- Use programmatic generation (`w.lineSM()`, `w.curlyBlock()`) for code that varies per schema/profile + ## Common Development Patterns ### Adding a New Generator Feature @@ -235,10 +266,11 @@ Located in `src/api/writer-generator/`: ### Generators - `src/api/writer-generator/introspection.ts` - TypeSchema introspection generation -- `src/api/writer-generator/typescript.ts` - TypeScript code generation +- `src/api/writer-generator/typescript/writer.ts` - TypeScript type generation +- `src/api/writer-generator/typescript/profile.ts` - TypeScript profile class generation - `src/api/writer-generator/python.ts` - Python/Pydantic generation -- `src/api/writer-generator/csharp.ts` - C# generation -- `src/api/writer-generator/base.ts` - Common writer utilities +- `src/api/writer-generator/csharp/csharp.ts` - C# generation +- `src/api/writer-generator/writer.ts` - Base Writer class (I/O, indentation, `cp()` for assets) ### FHIR Processing - `src/typeschema/register.ts` - Package registration and canonical resolution From fd34454d0800e7d3d0fa464fe06128f3ec437891 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 6 Mar 2026 13:41:39 +0100 Subject: [PATCH 6/6] Move tsSliceStaticName to name.ts, simplify if statements in profile.ts --- src/api/writer-generator/typescript/name.ts | 2 + .../writer-generator/typescript/profile.ts | 46 ++++++------------- 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/src/api/writer-generator/typescript/name.ts b/src/api/writer-generator/typescript/name.ts index 71d9fdeb5..aad27f8c9 100644 --- a/src/api/writer-generator/typescript/name.ts +++ b/src/api/writer-generator/typescript/name.ts @@ -99,6 +99,8 @@ export const tsExtensionInputTypeName = (profileName: string, extensionName: str return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(extensionName))}Input`; }; +export const tsSliceStaticName = (name: string): string => name.replace(/\[x\]/g, "").replace(/[^a-zA-Z0-9_$]/g, "_"); + export const tsSliceMethodName = (sliceName: string): string => { return `set${uppercaseFirstLetter(normalizeTsName(sliceName) || "Slice")}`; }; diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 24d6ad9ad..3911da5bc 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -34,12 +34,11 @@ import { tsResourceName, tsSliceInputTypeName, tsSliceMethodName, + tsSliceStaticName, } from "./name"; import { resolveFieldTsType, resolvePrimitiveType, tsEnumType, tsGet, tsTypeFromIdentifier } from "./utils"; import type { TypeScript } from "./writer"; -const tsSliceStaticName = (name: string): string => name.replace(/\[x\]/g, "").replace(/[^a-zA-Z0-9_$]/g, "_"); - type ProfileFactoryInfo = { autoFields: { name: string; value: string }[]; /** Array fields with required slices — optional param with auto-merge of required stubs */ @@ -277,25 +276,14 @@ const generateProfileHelpersImport = ( }, ) => { const imports: string[] = []; - if (options.needsRegisterProfile) { - imports.push("ensureProfile"); - } - if (options.needsSliceHelpers) { + if (options.needsRegisterProfile) imports.push("ensureProfile"); + if (options.needsSliceHelpers) imports.push("applySliceMatch", "matchesValue", "setArraySlice", "getArraySlice", "ensureSliceDefaults"); - } - if (options.needsGetOrCreateObjectAtPath) { - imports.push("ensurePath"); - } - if (options.needsExtensionExtraction) { - imports.push("extractComplexExtension"); - } - if (options.needsSliceExtraction) { - imports.push("stripMatchKeys"); - } - if (options.needsSliceChoiceHelpers) { - imports.push("wrapSliceChoice", "unwrapSliceChoice"); - } - if (options.needsValidation) { + if (options.needsGetOrCreateObjectAtPath) imports.push("ensurePath"); + if (options.needsExtensionExtraction) imports.push("extractComplexExtension"); + if (options.needsSliceExtraction) imports.push("stripMatchKeys"); + if (options.needsSliceChoiceHelpers) imports.push("wrapSliceChoice", "unwrapSliceChoice"); + if (options.needsValidation) imports.push( "validateRequired", "validateExcluded", @@ -305,10 +293,7 @@ const generateProfileHelpersImport = ( "validateReference", "validateChoiceRequired", ); - } - if (imports.length > 0) { - w.lineSM(`import { ${imports.join(", ")} } from "../../profile-helpers"`); - } + if (imports.length > 0) w.lineSM(`import { ${imports.join(", ")} } from "../../profile-helpers"`); }; const collectTypesFromSlices = ( @@ -1017,25 +1002,20 @@ const collectRegularFieldValidation = ( return; } - if (field.required) { - exprs.push(`...validateRequired(res, profileName, ${JSON.stringify(name)})`); - } + if (field.required) exprs.push(`...validateRequired(res, profileName, ${JSON.stringify(name)})`); - if (field.valueConstraint) { + if (field.valueConstraint) exprs.push( `...validateFixedValue(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(field.valueConstraint.value)})`, ); - } - if (field.enum && !field.enum.isOpen) { + if (field.enum && !field.enum.isOpen) exprs.push(`...validateEnum(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(field.enum.values)})`); - } - if (field.reference && field.reference.length > 0) { + if (field.reference && field.reference.length > 0) exprs.push( `...validateReference(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(field.reference.map((ref) => resolveRef(ref).name))})`, ); - } if (field.slicing?.slices) { for (const [sliceName, slice] of Object.entries(field.slicing.slices)) {