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 525fae454..2449e9d88 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 @@ -20,6 +20,7 @@ import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired export type observation_bodyweightProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); subject: Reference<"Patient">; + category?: CodeableConcept<("social-history" | "vital-signs" | "imaging" | "laboratory" | "procedure" | "survey" | "exam" | "therapy" | "activity" | string)>[]; } // CanonicalURL: http://hl7.org/fhir/StructureDefinition/bodyweight (pkg: hl7.fhir.r4.core#4.0.1) @@ -41,10 +42,13 @@ 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 = { resourceType: "Observation", - category: [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}], 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"] }, 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 571250e7c..db4fa14fd 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 @@ -15,14 +15,16 @@ export interface observation_bp extends Observation { } export type Observation_bp_Category_VSCatSliceInput = Omit; -export type Observation_bp_Component_SystolicBPSliceInput = Omit; -export type Observation_bp_Component_DiastolicBPSliceInput = Omit; +export type Observation_bp_Component_SystolicBPSliceInput = Omit & Quantity; +export type Observation_bp_Component_DiastolicBPSliceInput = Omit & Quantity; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { applySliceMatch, matchesSlice, extractSliceSimplified, wrapSliceChoice, flattenSliceChoice, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; export type observation_bpProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); subject: Reference<"Patient">; + category?: CodeableConcept<("social-history" | "vital-signs" | "imaging" | "laboratory" | "procedure" | "survey" | "exam" | "therapy" | "activity" | string)>[]; + component?: ObservationComponent[]; } // CanonicalURL: http://hl7.org/fhir/StructureDefinition/bp (pkg: hl7.fhir.r4.core#4.0.1) @@ -44,11 +46,18 @@ 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 = { resourceType: "Observation", - category: [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}], code: {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, - component: [{"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}},{"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}], + category: categoryWithDefaults, + component: componentWithDefaults, status: args.status, subject: args.subject, meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bp"] }, @@ -155,7 +164,7 @@ export class observation_bpProfile { 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 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) { @@ -168,7 +177,7 @@ export class observation_bpProfile { 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 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) { @@ -202,7 +211,7 @@ export class observation_bpProfile { if (!list) return undefined const item = list.find((item) => matchesSlice(item, match)) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["code"]) as Observation_bp_Component_SystolicBPSliceInput + return flattenSliceChoice(item as unknown as Record, ["code"], "valueQuantity") as Observation_bp_Component_SystolicBPSliceInput } public getSystolicBPRaw (): ObservationComponent | undefined { @@ -219,7 +228,7 @@ export class observation_bpProfile { if (!list) return undefined const item = list.find((item) => matchesSlice(item, match)) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["code"]) as Observation_bp_Component_DiastolicBPSliceInput + return flattenSliceChoice(item as unknown as Record, ["code"], "valueQuantity") as Observation_bp_Component_DiastolicBPSliceInput } public getDiastolicBPRaw (): ObservationComponent | undefined { 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 4bb7c72e0..ad0e95fa4 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 @@ -20,6 +20,7 @@ export type observation_vitalsignsProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); code: CodeableConcept<("85353-1" | "9279-1" | "8867-4" | "2708-6" | "8310-5" | "8302-2" | "9843-4" | "29463-7" | "39156-5" | "85354-9" | "8480-6" | "8462-4" | "8478-0" | string)>; subject: Reference<"Patient">; + category?: CodeableConcept<("social-history" | "vital-signs" | "imaging" | "laboratory" | "procedure" | "survey" | "exam" | "therapy" | "activity" | string)>[]; } // CanonicalURL: http://hl7.org/fhir/StructureDefinition/vitalsigns (pkg: hl7.fhir.r4.core#4.0.1) @@ -41,9 +42,12 @@ 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 = { resourceType: "Observation", - category: [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}], + category: categoryWithDefaults, status: args.status, code: args.code, subject: args.subject, diff --git a/examples/typescript-r4/fhir-types/profile-helpers.ts b/examples/typescript-r4/fhir-types/profile-helpers.ts index b75f10f83..ab04e6bd0 100644 --- a/examples/typescript-r4/fhir-types/profile-helpers.ts +++ b/examples/typescript-r4/fhir-types/profile-helpers.ts @@ -99,6 +99,24 @@ export const extractSliceSimplified = >(slice: return result as Partial; } +export const wrapSliceChoice = (input: Record, choiceVariant: string): Record => { + if (Object.keys(input).length === 0) return input; + return { [choiceVariant]: input }; +} + +export const flattenSliceChoice = (slice: Record, matchKeys: string[], choiceVariant: string): Record => { + 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; +} + 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; } diff --git a/examples/typescript-r4/profile-bp.test.ts b/examples/typescript-r4/profile-bp.test.ts index 6f6afed5b..78385e502 100644 --- a/examples/typescript-r4/profile-bp.test.ts +++ b/examples/typescript-r4/profile-bp.test.ts @@ -44,12 +44,13 @@ describe("blood pressure profile", () => { }); test("setSystolicBP / getSystolicBP / getSystolicBPRaw", () => { - profile.setSystolicBP({ - valueQuantity: { value: 120, unit: "mmHg", system: "http://unitsofmeasure.org", code: "mm[Hg]" }, - }); + profile.setSystolicBP({ value: 120, unit: "mmHg", system: "http://unitsofmeasure.org", code: "mm[Hg]" }); expect(profile.getSystolicBP()).toEqual({ - valueQuantity: { value: 120, unit: "mmHg", system: "http://unitsofmeasure.org", code: "mm[Hg]" }, + value: 120, + unit: "mmHg", + system: "http://unitsofmeasure.org", + code: "mm[Hg]", }); expect(profile.getSystolicBPRaw() as unknown).toEqual({ @@ -59,12 +60,13 @@ describe("blood pressure profile", () => { }); test("setDiastolicBP / getDiastolicBP / getDiastolicBPRaw", () => { - profile.setDiastolicBP({ - valueQuantity: { value: 80, unit: "mmHg", system: "http://unitsofmeasure.org", code: "mm[Hg]" }, - }); + profile.setDiastolicBP({ value: 80, unit: "mmHg", system: "http://unitsofmeasure.org", code: "mm[Hg]" }); expect(profile.getDiastolicBP()).toEqual({ - valueQuantity: { value: 80, unit: "mmHg", system: "http://unitsofmeasure.org", code: "mm[Hg]" }, + value: 80, + unit: "mmHg", + system: "http://unitsofmeasure.org", + code: "mm[Hg]", }); expect(profile.getDiastolicBPRaw() as unknown).toEqual({ @@ -87,7 +89,7 @@ describe("blood pressure profile", () => { }); test("setSystolicBP replaces an existing systolic component", () => { - profile.setSystolicBP({ valueQuantity: { value: 130, unit: "mmHg" } }); + profile.setSystolicBP({ value: 130, unit: "mmHg" }); expect(profile.toResource().component).toHaveLength(2); expect(profile.getSystolicBPRaw()!.valueQuantity!.value).toBe(130); @@ -116,8 +118,8 @@ describe("blood pressure profile", () => { .setVSCat({ text: "Vital Signs" }) .setEffectiveDateTime("2024-06-15") .setSubject({ reference: "Patient/pt-2" }) - .setSystolicBP({ valueQuantity: { value: 120, unit: "mmHg" } }) - .setDiastolicBP({ valueQuantity: { value: 80, unit: "mmHg" } }); + .setSystolicBP({ value: 120, unit: "mmHg" }) + .setDiastolicBP({ value: 80, unit: "mmHg" }); expect(result).toBe(profile); expect(profile.getStatus()).toBe("final"); @@ -137,4 +139,51 @@ describe("blood pressure profile", () => { expect(rawCode.coding).toEqual({ code: "8480-6", system: "http://loinc.org" }); expect(raw.valueQuantity).toBeUndefined(); }); + + test("create() with custom category preserves user values and adds required VSCat", () => { + const custom = bpProfile.create({ + status: "final", + subject: { reference: "Patient/pt-1" }, + category: [{ text: "My Category" }], + }); + const obs = custom.toResource(); + // User category kept, VSCat auto-added + expect(obs.category).toHaveLength(2); + expect(obs.category![0]!.text).toBe("My Category"); + expect((obs.category![1] as Record).coding).toEqual({ + code: "vital-signs", + system: "http://terminology.hl7.org/CodeSystem/observation-category", + }); + }); + + test("create() with empty category still adds required VSCat", () => { + const custom = bpProfile.create({ + status: "final", + subject: { reference: "Patient/pt-1" }, + category: [], + }); + const obs = custom.toResource(); + expect(obs.category).toHaveLength(1); + expect((obs.category![0] as Record).coding).toEqual({ + code: "vital-signs", + system: "http://terminology.hl7.org/CodeSystem/observation-category", + }); + }); + + test("create() with category already containing VSCat does not duplicate it", () => { + const custom = bpProfile.create({ + status: "final", + subject: { reference: "Patient/pt-1" }, + category: [ + { + coding: { + code: "vital-signs", + system: "http://terminology.hl7.org/CodeSystem/observation-category", + } as unknown, + } as never, + ], + }); + const obs = custom.toResource(); + expect(obs.category).toHaveLength(1); + }); }); diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 81467f265..82d638727 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -40,6 +40,8 @@ import type { TypeScript } from "./writer"; 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[] }[]; params: { name: string; tsType: string; typeId: Identifier }[]; accessors: { name: string; tsType: string; typeId: Identifier }[]; }; @@ -86,6 +88,7 @@ const collectRequiredSliceMatches = (field: RegularField): Record { const autoFields: ProfileFactoryInfo["autoFields"] = []; + const sliceAutoFields: ProfileFactoryInfo["sliceAutoFields"] = []; const params: ProfileFactoryInfo["params"] = []; const autoAccessors: ProfileFactoryInfo["accessors"] = []; const fields = flatProfile.fields ?? {}; @@ -117,10 +120,16 @@ const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFacto if (isNotChoiceDeclarationField(field)) { const requiredMatches = collectRequiredSliceMatches(field); if (requiredMatches) { - const value = `[${requiredMatches.map((m) => JSON.stringify(m)).join(",")}]`; - autoFields.push({ name, value }); + const defaultValue = `[${requiredMatches.map((m) => JSON.stringify(m)).join(",")}]`; if (field.type) { const tsType = resolveFieldTsType("", "", field) + (field.array ? "[]" : ""); + sliceAutoFields.push({ + name, + tsType, + typeId: field.type, + defaultValue, + matches: requiredMatches.map((m) => JSON.stringify(m)), + }); autoAccessors.push({ name, tsType, typeId: field.type }); } continue; @@ -134,7 +143,7 @@ const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFacto } const accessors = [...autoAccessors, ...collectChoiceAccessors(flatProfile, promotedChoices)]; - return { autoFields, params, accessors }; + return { autoFields, sliceAutoFields, params, accessors }; }; export const generateProfileIndexFile = ( @@ -385,6 +394,44 @@ export const generateProfileHelpersModule = (w: TypeScript) => { }, ); 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( [ @@ -520,6 +567,7 @@ const generateProfileHelpersImport = ( needsSliceHelpers: boolean; needsExtensionExtraction: boolean; needsSliceExtraction: boolean; + needsSliceChoiceHelpers: boolean; needsValidation: boolean; }, ) => { @@ -536,6 +584,9 @@ const generateProfileHelpersImport = ( if (options.needsSliceExtraction) { imports.push("extractSliceSimplified"); } + if (options.needsSliceChoiceHelpers) { + imports.push("wrapSliceChoice", "flattenSliceChoice"); + } if (options.needsValidation) { imports.push( "validateRequired", @@ -551,12 +602,19 @@ const generateProfileHelpersImport = ( } }; -const collectTypesFromSlices = (flatProfile: ProfileTypeSchema, addType: (typeId: Identifier) => void) => { +const collectTypesFromSlices = ( + tsIndex: TypeSchemaIndex, + flatProfile: ProfileTypeSchema, + addType: (typeId: Identifier) => void, +) => { for (const field of Object.values(flatProfile.fields ?? {})) { if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) continue; for (const slice of Object.values(field.slicing.slices)) { if (Object.keys(slice.match ?? {}).length > 0) { addType(field.type); + // Also add constrained choice variant types for flattened API + const cc = detectConstrainedChoice(tsIndex, flatProfile, field.type, slice.elements); + if (cc) addType(cc.variantTypeId); } } } @@ -641,12 +699,13 @@ export const generateProfileImports = (w: TypeScript, tsIndex: TypeSchemaIndex, }; addType(flatProfile.base); - collectTypesFromSlices(flatProfile, addType); + collectTypesFromSlices(tsIndex, flatProfile, addType); const needsExtensionType = collectTypesFromExtensions(tsIndex, flatProfile, addType); collectTypesFromFieldOverrides(tsIndex, flatProfile, addType); const factoryInfo = collectProfileFactoryInfo(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); if (needsExtensionType) { @@ -677,6 +736,52 @@ const collectExtSliceMethodSuffixes = ( return suffixes; }; +type ConstrainedChoice = { + choiceBase: string; + variant: string; + variantType: string; + variantTypeId: Identifier; + allChoiceNames: string[]; +}; + +/** + * Detect if a slice constrains a polymorphic field to exactly one variant. + * E.g., BP systolic slice constrains value[x] (11 variants) to only valueQuantity. + */ +const detectConstrainedChoice = ( + tsIndex: TypeSchemaIndex, + flatProfile: ProfileTypeSchema, + sliceBaseTypeId: Identifier, + sliceElements: string[] | undefined, +): ConstrainedChoice | undefined => { + if (!sliceElements) return undefined; + + // Resolve the base type's TypeSchema to find choice declarations + const baseSchema = tsIndex.resolveByUrl(flatProfile.identifier.package, sliceBaseTypeId.url as CanonicalUrl); + if (!baseSchema || !("fields" in baseSchema) || !baseSchema.fields) return undefined; + + for (const [fieldName, field] of Object.entries(baseSchema.fields)) { + if (!isChoiceDeclarationField(field)) continue; + + // Find which choice instances are in the slice elements + const matchingVariants = field.choices.filter((c) => sliceElements.includes(c)); + if (matchingVariants.length !== 1) continue; + + const variantName = matchingVariants[0] as string; + const variantField = baseSchema.fields[variantName]; + if (!variantField || !isChoiceInstanceField(variantField)) continue; + + return { + choiceBase: fieldName, + variant: variantName, + variantType: tsTypeFromIdentifier(variantField.type), + variantTypeId: variantField.type, + allChoiceNames: field.choices, + }; + } + return undefined; +}; + export const generateProfileClass = ( w: TypeScript, tsIndex: TypeSchemaIndex, @@ -731,6 +836,7 @@ export const generateProfileClass = ( const filteredRequired = required.filter( (name) => !matchFields.includes(name) && !polymorphicBaseNames.has(name), ); + const constrainedChoice = detectConstrainedChoice(tsIndex, flatProfile, field.type, slice.elements); return { fieldName, baseType, @@ -741,6 +847,7 @@ export const generateProfileClass = ( array: Boolean(field.array), // Input is optional when there are no required fields after filtering inputOptional: filteredRequired.length === 0, + constrainedChoice, }; }); }); @@ -766,6 +873,14 @@ export const generateProfileClass = ( const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); const matchFields = Object.keys(sliceDef.match); const allExcluded = [...new Set([...sliceDef.excluded, ...matchFields])]; + // When a choice is constrained to a single variant, also omit the choice base + all instances + if (sliceDef.constrainedChoice) { + const cc = sliceDef.constrainedChoice; + allExcluded.push(cc.choiceBase); + for (const name of cc.allChoiceNames) { + if (!allExcluded.includes(name)) allExcluded.push(name); + } + } const excludedNames = allExcluded.map((name) => JSON.stringify(name)); // Filter out polymorphic base names that don't exist as direct TS properties const filteredRequired = sliceDef.required.filter( @@ -779,13 +894,21 @@ export const generateProfileClass = ( if (requiredNames.length > 0) { typeExpr = `${typeExpr} & Required>`; } + // Intersect with the single variant's type for flattened API + if (sliceDef.constrainedChoice) { + typeExpr = `${typeExpr} & ${sliceDef.constrainedChoice.variantType}`; + } w.lineSM(`export type ${typeName} = ${typeExpr}`); } w.line(); } + // Check if we have an override interface (narrowed types) + const hasOverrideInterface = detectFieldOverrides(w, tsIndex, flatProfile).size > 0; + const factoryInfo = collectProfileFactoryInfo(flatProfile); + // Determine which helpers are actually needed - const needsSliceHelpers = sliceDefs.length > 0; + const needsSliceHelpers = sliceDefs.length > 0 || factoryInfo.sliceAutoFields.length > 0; const extensionsWithNestedPath = extensions.filter((ext) => { const targetPath = ext.path.split(".").filter((segment) => segment !== "extension"); return targetPath.length > 0; @@ -793,6 +916,7 @@ export const generateProfileClass = ( const needsGetOrCreateObjectAtPath = extensionsWithNestedPath.length > 0; const needsExtensionExtraction = complexExtensions.length > 0; const needsSliceExtraction = sliceDefs.length > 0; + const needsSliceChoiceHelpers = sliceDefs.some((s) => s.constrainedChoice); const needsValidation = Object.keys(flatProfile.fields ?? {}).length > 0; @@ -801,6 +925,7 @@ export const generateProfileClass = ( needsGetOrCreateObjectAtPath || needsExtensionExtraction || needsSliceExtraction || + needsSliceChoiceHelpers || needsValidation ) { generateProfileHelpersImport(w, { @@ -808,20 +933,18 @@ export const generateProfileClass = ( needsSliceHelpers, needsExtensionExtraction, needsSliceExtraction, + needsSliceChoiceHelpers, needsValidation, }); w.line(); } - // Check if we have an override interface (narrowed types) - const hasOverrideInterface = detectFieldOverrides(w, tsIndex, flatProfile).size > 0; - const factoryInfo = collectProfileFactoryInfo(flatProfile); - - const hasParams = factoryInfo.params.length > 0; + const hasParams = factoryInfo.params.length > 0 || factoryInfo.sliceAutoFields.length > 0; const createArgsTypeName = `${profileClassName}Params`; const paramSignature = hasParams ? `args: ${createArgsTypeName}` : ""; const allFields = [ ...factoryInfo.autoFields.map((f) => ({ name: f.name, value: f.value })), + ...factoryInfo.sliceAutoFields.map((f) => ({ name: f.name, value: `${f.name}WithDefaults` })), ...factoryInfo.params.map((p) => ({ name: p.name, value: `args.${p.name}` })), ]; @@ -830,6 +953,9 @@ export const generateProfileClass = ( for (const p of factoryInfo.params) { w.lineSM(`${p.name}: ${p.tsType}`); } + for (const f of factoryInfo.sliceAutoFields) { + w.lineSM(`${f.name}?: ${f.tsType}`); + } }); w.line(); } @@ -863,6 +989,17 @@ export const generateProfileClass = ( }); w.line(); 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}]!)`, + ); + } + } w.curlyBlock([`const resource: ${tsBaseResourceName} =`], () => { for (const f of allFields) { w.line(`${f.name}: ${f.value},`); @@ -968,7 +1105,14 @@ export const generateProfileClass = ( const inputExpr = sliceDef.inputOptional ? "(input ?? {}) as Record" : "input as Record"; - w.line(`const value = applySliceMatch(${inputExpr}, match) as unknown as ${sliceDef.baseType}`); + if (sliceDef.constrainedChoice) { + const cc = sliceDef.constrainedChoice; + w.line( + `const value = applySliceMatch(wrapSliceChoice(${inputExpr}, ${JSON.stringify(cc.variant)}), match) as unknown as ${sliceDef.baseType}`, + ); + } else { + w.line(`const value = applySliceMatch(${inputExpr}, match) as unknown as ${sliceDef.baseType}`); + } if (sliceDef.array) { w.line(`const list = (${fieldAccess} ??= [])`); w.line("const index = list.findIndex((item) => matchesSlice(item, match))"); @@ -1111,9 +1255,16 @@ export const generateProfileClass = ( if (sliceDef.array) { w.line("if (!item) return undefined"); } - w.line( - `return extractSliceSimplified(item as unknown as Record, ${matchKeys}) as ${typeName}`, - ); + if (sliceDef.constrainedChoice) { + const cc = sliceDef.constrainedChoice; + w.line( + `return flattenSliceChoice(item as unknown as Record, ${matchKeys}, ${JSON.stringify(cc.variant)}) as ${typeName}`, + ); + } else { + w.line( + `return extractSliceSimplified(item as unknown as Record, ${matchKeys}) as ${typeName}`, + ); + } }); w.line(); diff --git a/src/typeschema/core/field-builder.ts b/src/typeschema/core/field-builder.ts index 14ddca4ad..3676adce0 100644 --- a/src/typeschema/core/field-builder.ts +++ b/src/typeschema/core/field-builder.ts @@ -73,7 +73,7 @@ const buildReferences = ( }); }; -const extractSliceFieldNames = (schema: FHIRSchemaElement): Pick => { +const extractSliceFieldNames = (schema: FHIRSchemaElement): Pick => { const required = new Set(); const excluded = new Set(); @@ -91,9 +91,12 @@ const extractSliceFieldNames = (schema: FHIRSchemaElement): Pick 0 ? Array.from(required) : undefined, excluded: excluded.size > 0 ? Array.from(excluded) : undefined, + elements: elements && elements.length > 0 ? elements : undefined, }; }; @@ -195,7 +198,7 @@ const buildSlicing = (element: FHIRSchemaElement): FieldSlicing | undefined => { const slices: Record = {}; for (const [name, slice] of Object.entries(slicing.slices ?? {})) { if (!slice) continue; - const { required, excluded } = slice.schema ? extractSliceFieldNames(slice.schema) : {}; + const { required, excluded, elements } = slice.schema ? extractSliceFieldNames(slice.schema) : {}; slices[name] = { min: slice.min, max: slice.max, @@ -204,6 +207,7 @@ const buildSlicing = (element: FHIRSchemaElement): FieldSlicing | undefined => { : (slice.match as Record | undefined), required, excluded, + elements, }; } diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index 8dd56dd28..35b214c07 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -223,6 +223,7 @@ export interface FieldSlice { match?: Record; required?: string[]; excluded?: string[]; + elements?: string[]; } export interface ExtensionSubField { diff --git a/test/api/write-generator/__snapshots__/typescript.test.ts.snap b/test/api/write-generator/__snapshots__/typescript.test.ts.snap index 7ac6dd141..c100b9264 100644 --- a/test/api/write-generator/__snapshots__/typescript.test.ts.snap +++ b/test/api/write-generator/__snapshots__/typescript.test.ts.snap @@ -334,6 +334,7 @@ import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired export type observation_bodyweightProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); subject: Reference<"Patient">; + category?: CodeableConcept<("social-history" | "vital-signs" | "imaging" | "laboratory" | "procedure" | "survey" | "exam" | "therapy" | "activity" | string)>[]; } // CanonicalURL: http://hl7.org/fhir/StructureDefinition/bodyweight (pkg: hl7.fhir.r4.core#4.0.1) @@ -355,10 +356,13 @@ 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 = { resourceType: "Observation", - category: [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}], 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"] }, @@ -521,6 +525,8 @@ import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired export type observation_bpProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); subject: Reference<"Patient">; + category?: CodeableConcept<("social-history" | "vital-signs" | "imaging" | "laboratory" | "procedure" | "survey" | "exam" | "therapy" | "activity" | string)>[]; + component?: ObservationComponent[]; } // CanonicalURL: http://hl7.org/fhir/StructureDefinition/bp (pkg: hl7.fhir.r4.core#4.0.1) @@ -542,11 +548,18 @@ 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 = { resourceType: "Observation", - category: [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}], code: {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, - component: [{"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}},{"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}], + category: categoryWithDefaults, + component: componentWithDefaults, status: args.status, subject: args.subject, meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bp"] },