From 094f1617558c7f7c47e194f8df7f9015d4aa5bc9 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 26 Feb 2026 19:01:27 +0100 Subject: [PATCH 1/8] Move TypeScript writer to typescript/writer.ts subdirectory --- src/api/builder.ts | 2 +- src/api/index.ts | 2 +- .../writer-generator/{typescript.ts => typescript/writer.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/api/writer-generator/{typescript.ts => typescript/writer.ts} (100%) diff --git a/src/api/builder.ts b/src/api/builder.ts index b3bba757..82576dcf 100644 --- a/src/api/builder.ts +++ b/src/api/builder.ts @@ -29,7 +29,7 @@ import { IntrospectionWriter, type IntrospectionWriterOptions } from "./writer-g import { IrReportWriterWriter, type IrReportWriterWriterOptions } from "./writer-generator/ir-report"; import type { FileBasedMustacheGeneratorOptions } from "./writer-generator/mustache"; import * as Mustache from "./writer-generator/mustache"; -import { TypeScript, type TypeScriptOptions } from "./writer-generator/typescript"; +import { TypeScript, type TypeScriptOptions } from "./writer-generator/typescript/writer"; import type { FileBuffer, FileSystemWriter, FileSystemWriterOptions, WriterOptions } from "./writer-generator/writer"; /** diff --git a/src/api/index.ts b/src/api/index.ts index 673f2ea4..2dbd32b3 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -12,4 +12,4 @@ export { LogLevel } from "../utils/codegen-logger"; export type { APIBuilderOptions, LocalStructureDefinitionConfig } from "./builder"; export { APIBuilder, prettyReport } from "./builder"; export type { CSharpGeneratorOptions } from "./writer-generator/csharp/csharp"; -export type { TypeScriptOptions } from "./writer-generator/typescript"; +export type { TypeScriptOptions } from "./writer-generator/typescript/writer"; diff --git a/src/api/writer-generator/typescript.ts b/src/api/writer-generator/typescript/writer.ts similarity index 100% rename from src/api/writer-generator/typescript.ts rename to src/api/writer-generator/typescript/writer.ts From 74ff30d5622bf6ddc80d155b5b7f3a14a28a1084 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 26 Feb 2026 19:57:55 +0100 Subject: [PATCH 2/8] Extract helpers and profile code from TypeScript writer Split writer.ts (1651 lines) into three files: - utils.ts: standalone utility functions and constants - profile.ts: profile-specific helpers and extracted class methods - writer.ts: TypeScript class with non-profile methods (317 lines) --- .../writer-generator/typescript/profile.ts | 1235 +++++++++++++++ src/api/writer-generator/typescript/utils.ts | 159 ++ src/api/writer-generator/typescript/writer.ts | 1382 +---------------- 3 files changed, 1418 insertions(+), 1358 deletions(-) create mode 100644 src/api/writer-generator/typescript/profile.ts create mode 100644 src/api/writer-generator/typescript/utils.ts diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts new file mode 100644 index 00000000..a17a04f8 --- /dev/null +++ b/src/api/writer-generator/typescript/profile.ts @@ -0,0 +1,1235 @@ +import { pascalCase, typeSchemaInfo, uppercaseFirstLetter } from "@root/api/writer-generator/utils"; +import { + type CanonicalUrl, + type Identifier, + isChoiceDeclarationField, + isChoiceInstanceField, + isNestedIdentifier, + isNotChoiceDeclarationField, + isPrimitiveIdentifier, + isResourceIdentifier, + isSpecializationTypeSchema, + type ProfileExtension, + type ProfileTypeSchema, + packageMeta, + packageMetaToFhir, + type TypeSchema, +} from "@root/typeschema/types"; +import type { TypeSchemaIndex } from "@root/typeschema/utils"; +import { + canonicalToName, + normalizeTsName, + resolveFieldTsType, + resolvePrimitiveType, + safeCamelCase, + tsEnumType, + tsFhirPackageDir, + tsFieldName, + tsGet, + tsModulePath, + tsResourceName, + tsTypeFromIdentifier, +} from "./utils"; +import type { TypeScript } from "./writer"; + +export const tsProfileModuleName = (tsIndex: TypeSchemaIndex, schema: ProfileTypeSchema): string => { + const resourceSchema = tsIndex.findLastSpecialization(schema); + const resourceName = uppercaseFirstLetter(normalizeTsName(resourceSchema.identifier.name)); + return `${resourceName}_${normalizeTsName(schema.identifier.name)}`; +}; + +export const tsProfileModuleFileName = (tsIndex: TypeSchemaIndex, schema: ProfileTypeSchema): string => { + return `${tsProfileModuleName(tsIndex, schema)}.ts`; +}; + +export const tsProfileClassName = (schema: ProfileTypeSchema): string => { + return `${normalizeTsName(schema.identifier.name)}Profile`; +}; + +export type ProfileFactoryInfo = { + autoFields: { name: string; value: string }[]; + params: { name: string; tsType: string; typeId: Identifier }[]; +}; + +export const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFactoryInfo => { + const autoFields: ProfileFactoryInfo["autoFields"] = []; + const params: ProfileFactoryInfo["params"] = []; + const fields = flatProfile.fields ?? {}; + + if (isResourceIdentifier(flatProfile.base)) { + autoFields.push({ name: "resourceType", value: JSON.stringify(flatProfile.base.name) }); + } + + for (const [name, field] of Object.entries(fields)) { + if (isChoiceInstanceField(field)) continue; + if (field.excluded) continue; + + // Required choice declaration with a single choice — promote that choice to a param + if (isChoiceDeclarationField(field)) { + if (field.required && field.choices.length === 1) { + const choiceName = field.choices[0]; + if (choiceName) { + const choiceField = fields[choiceName]; + if (choiceField && isChoiceInstanceField(choiceField)) { + const tsType = tsTypeFromIdentifier(choiceField.type) + (choiceField.array ? "[]" : ""); + params.push({ name: choiceName, tsType, typeId: choiceField.type }); + } + } + } + continue; + } + + if (field.valueConstraint) { + const value = JSON.stringify(field.valueConstraint.value); + autoFields.push({ name, value: field.array ? `[${value}]` : value }); + continue; + } + + if (field.required) { + const tsType = resolveFieldTsType("", "", field) + (field.array ? "[]" : ""); + params.push({ name, tsType, typeId: field.type }); + } + } + + return { autoFields, params }; +}; + +export const tsSliceInputTypeName = (profileName: string, fieldName: string, sliceName: string): string => { + return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(fieldName))}_${uppercaseFirstLetter(normalizeTsName(sliceName))}SliceInput`; +}; + +export const tsExtensionInputTypeName = (profileName: string, extensionName: string): string => { + return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(extensionName))}Input`; +}; + +export const tsSliceMethodName = (sliceName: string): string => { + const normalized = safeCamelCase(sliceName); + return `set${uppercaseFirstLetter(normalized || "Slice")}`; +}; + +export const tsExtensionMethodName = (name: string): string => { + const normalized = safeCamelCase(name); + return `set${uppercaseFirstLetter(normalized || "Extension")}`; +}; + +export const tsExtensionMethodFallback = (name: string, path?: string): string => { + const rawPath = + path + ?.split(".") + .filter((p) => p && p !== "extension") + .join("_") ?? ""; + const pathPart = rawPath ? uppercaseFirstLetter(safeCamelCase(rawPath)) : ""; + const normalized = safeCamelCase(name); + return `setExtension${pathPart}${uppercaseFirstLetter(normalized || "Extension")}`; +}; + +export const tsSliceMethodFallback = (fieldName: string, sliceName: string): string => { + const fieldPart = uppercaseFirstLetter(safeCamelCase(fieldName) || "Field"); + const slicePart = uppercaseFirstLetter(safeCamelCase(sliceName) || "Slice"); + return `setSlice${fieldPart}${slicePart}`; +}; + +export const generateProfileIndexFile = ( + writer: TypeScript, + tsIndex: TypeSchemaIndex, + initialProfiles: ProfileTypeSchema[], +) => { + if (initialProfiles.length === 0) return; + writer.cd("profiles", () => { + writer.cat("index.ts", () => { + const profiles: [ProfileTypeSchema, string, string | undefined][] = initialProfiles.map((profile) => { + const className = tsProfileClassName(profile); + const resourceName = tsResourceName(profile.identifier); + const overrides = detectFieldOverrides(writer, tsIndex, profile); + let typeExport; + if (overrides.size > 0) typeExport = resourceName; + return [profile, className, typeExport]; + }); + if (profiles.length === 0) return; + const classExports: Map = new Map(); + const typeExports: Map = new Map(); + for (const [profile, className, typeName] of profiles) { + const moduleName = tsProfileModuleName(tsIndex, profile); + if (!classExports.has(className)) { + classExports.set(className, `export { ${className} } from "./${moduleName}"`); + } + if (typeName && !typeExports.has(typeName)) { + typeExports.set(typeName, `export type { ${typeName} } from "./${moduleName}"`); + } + } + const allExports = [...classExports.values(), ...typeExports.values()].sort(); + for (const exp of allExports) { + writer.lineSM(exp); + } + }); + }); +}; + +const tsTypeForProfileField = ( + _writer: TypeScript, + tsIndex: TypeSchemaIndex, + flatProfile: ProfileTypeSchema, + fieldName: string, + field: NonNullable[string], +): string => { + if (!isNotChoiceDeclarationField(field)) { + throw new Error(`Choice declaration fields not supported for '${fieldName}'`); + } + + let tsType: string; + if (field.enum) { + if (field.type?.name === "Coding") { + tsType = `Coding<${tsEnumType(field.enum)}>`; + } else if (field.type?.name === "CodeableConcept") { + tsType = `CodeableConcept<${tsEnumType(field.enum)}>`; + } else { + tsType = tsEnumType(field.enum); + } + } else if (field.reference && field.reference.length > 0) { + const specialization = tsIndex.findLastSpecialization(flatProfile); + if (!isSpecializationTypeSchema(specialization)) + throw new Error(`Invalid specialization for ${flatProfile.identifier}`); + + const sField = specialization.fields?.[fieldName]; + if (sField === undefined || isChoiceDeclarationField(sField) || sField.reference === undefined) + throw new Error(`Invalid field declaration for ${fieldName}`); + + const sRefs = sField.reference.map((e) => e.name); + const references = field.reference + .map((ref) => { + const resRef = tsIndex.findLastSpecializationByIdentifier(ref); + if (resRef.name !== ref.name) { + return `"${resRef.name}" /*${ref.name}*/`; + } + return `'${ref.name}'`; + }) + .join(" | "); + if (sRefs.length === 1 && sRefs[0] === "Resource" && references !== '"Resource"') { + // FIXME: should be generilized to type families + // Strip inner comments to avoid nested /* */ which is invalid + const cleanRefs = references.replace(/\/\*[^*]*\*\//g, "").trim(); + tsType = `Reference<"Resource" /* ${cleanRefs} */ >`; + } else { + tsType = `Reference<${references}>`; + } + } else if (isPrimitiveIdentifier(field.type)) { + tsType = resolvePrimitiveType(field.type.name); + } else if (isNestedIdentifier(field.type)) { + tsType = tsResourceName(field.type); + } else if (field.type === undefined) { + throw new Error(`Undefined type for '${fieldName}' field at ${typeSchemaInfo(flatProfile)}`); + } else { + tsType = field.type.name; + } + + return tsType; +}; + +export const generateProfileType = (writer: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { + writer.debugComment("flatProfile", flatProfile); + const tsName = tsResourceName(flatProfile.identifier); + writer.debugComment("identifier", flatProfile.identifier); + writer.debugComment("base", flatProfile.base); + writer.curlyBlock(["export", "interface", tsName], () => { + writer.lineSM(`__profileUrl: "${flatProfile.identifier.url}"`); + writer.line(); + + for (const [fieldName, field] of Object.entries(flatProfile.fields ?? {})) { + if (isChoiceDeclarationField(field)) continue; + writer.debugComment(fieldName, field); + + const tsName = tsFieldName(fieldName); + const tsType = tsTypeForProfileField(writer, tsIndex, flatProfile, fieldName, field); + writer.lineSM(`${tsName}${!field.required ? "?" : ""}: ${tsType}${field.array ? "[]" : ""}`); + } + }); + + writer.line(); +}; + +export const generateAttachProfile = (writer: TypeScript, flatProfile: ProfileTypeSchema) => { + const tsBaseResourceName = tsResourceName(flatProfile.base); + const tsProfileName = tsResourceName(flatProfile.identifier); + const profileFields = Object.entries(flatProfile.fields || {}) + .filter(([_fieldName, field]) => { + return field && isNotChoiceDeclarationField(field) && field.type !== undefined; + }) + .map(([fieldName]) => tsFieldName(fieldName)); + + writer.curlyBlock( + [ + `export const attach_${tsProfileName}_to_${tsBaseResourceName} =`, + `(resource: ${tsBaseResourceName}, profile: ${tsProfileName}): ${tsBaseResourceName}`, + "=>", + ], + () => { + writer.curlyBlock(["return"], () => { + writer.line("...resource,"); + // FIXME: don't rewrite all profiles + writer.curlyBlock(["meta:"], () => { + writer.line(`profile: ['${flatProfile.identifier.url}']`); + }, [","]); + profileFields.forEach((fieldName) => { + writer.line(`${fieldName}: ${tsGet("profile", fieldName)},`); + }); + }); + }, + ); + writer.line(); +}; + +export const generateExtractProfile = ( + writer: TypeScript, + tsIndex: TypeSchemaIndex, + flatProfile: ProfileTypeSchema, +) => { + const tsBaseResourceName = tsResourceName(flatProfile.base); + const tsProfileName = tsResourceName(flatProfile.identifier); + + const profileFields = Object.entries(flatProfile.fields || {}) + .filter(([_fieldName, field]) => { + return isNotChoiceDeclarationField(field) && field.type !== undefined; + }) + .map(([fieldName]) => fieldName); + + const specialization = tsIndex.findLastSpecialization(flatProfile); + if (!isSpecializationTypeSchema(specialization)) + throw new Error(`Specialization not found for ${flatProfile.identifier.url}`); + + const shouldCast: Record = {}; + writer.curlyBlock( + [ + `export const extract_${tsProfileName}_from_${tsBaseResourceName} =`, + `(resource: ${tsBaseResourceName}): ${tsProfileName}`, + "=>", + ], + () => { + profileFields.forEach((fieldName) => { + const tsField = tsFieldName(fieldName); + const pField = flatProfile.fields?.[fieldName]; + const rField = specialization.fields?.[fieldName]; + if (!isNotChoiceDeclarationField(pField) || !isNotChoiceDeclarationField(rField)) return; + + if (pField.required && !rField.required) { + writer.curlyBlock([`if (${tsGet("resource", tsField)} === undefined)`], () => + writer.lineSM(`throw new Error("'${tsField}' is required for ${flatProfile.identifier.url}")`), + ); + } + + const pRefs = pField?.reference?.map((ref) => ref.name); + const rRefs = rField?.reference?.map((ref) => ref.name); + if (pRefs && rRefs && pRefs.length !== rRefs.length) { + const predName = `reference_is_valid_${tsField}`; + writer.curlyBlock(["const", predName, "=", "(ref?: Reference)", "=>"], () => { + writer.line("return !ref"); + writer.indentBlock(() => { + rRefs.forEach((ref) => { + writer.line(`|| ref.reference?.startsWith('${ref}/')`); + }); + writer.line(";"); + }); + }); + let cond: string = !pField?.required ? `!${tsGet("resource", tsField)} || ` : ""; + if (pField.array) { + cond += `${tsGet("resource", tsField)}.every( (ref) => ${predName}(ref) )`; + } else { + cond += `!${predName}(${tsGet("resource", tsField)})`; + } + writer.curlyBlock(["if (", cond, ")"], () => { + writer.lineSM( + `throw new Error("'${fieldName}' has different references in profile and specialization")`, + ); + }); + writer.line(); + shouldCast[fieldName] = true; + } + }); + writer.curlyBlock(["return"], () => { + writer.line(`__profileUrl: '${flatProfile.identifier.url}',`); + profileFields.forEach((fieldName) => { + const tsField = tsFieldName(fieldName); + if (shouldCast[fieldName]) { + writer.line(`${tsField}:`, `${tsGet("resource", tsField)} as ${tsProfileName}['${tsField}'],`); + } else { + writer.line(`${tsField}:`, `${tsGet("resource", tsField)},`); + } + }); + }); + }, + ); +}; + +export const generateProfileHelpersModule = (writer: TypeScript) => { + writer.cat("profile-helpers.ts", () => { + writer.generateDisclaimer(); + writer.curlyBlock( + ["export const", "isRecord", "=", "(value: unknown): value is Record", "=>"], + () => { + writer.lineSM('return value !== null && typeof value === "object" && !Array.isArray(value)'); + }, + ); + writer.line(); + writer.curlyBlock( + [ + "export const", + "getOrCreateObjectAtPath", + "=", + "(root: Record, path: string[]): Record", + "=>", + ], + () => { + writer.lineSM("let current: Record = root"); + writer.curlyBlock(["for (const", "segment", "of", "path)"], () => { + writer.curlyBlock(["if", "(Array.isArray(current[segment]))"], () => { + writer.lineSM("const list = current[segment] as unknown[]"); + writer.curlyBlock(["if", "(list.length === 0)"], () => { + writer.lineSM("list.push({})"); + }); + writer.lineSM("current = list[0] as Record"); + }); + writer.curlyBlock(["else"], () => { + writer.curlyBlock(["if", "(!isRecord(current[segment]))"], () => { + writer.lineSM("current[segment] = {}"); + }); + writer.lineSM("current = current[segment] as Record"); + }); + }); + writer.lineSM("return current"); + }, + ); + writer.line(); + writer.curlyBlock( + [ + "export const", + "mergeMatch", + "=", + "(target: Record, match: Record): void", + "=>", + ], + () => { + writer.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { + writer.curlyBlock( + ["if", '(key === "__proto__" || key === "constructor" || key === "prototype")'], + () => { + writer.lineSM("continue"); + }, + ); + writer.curlyBlock(["if", "(isRecord(matchValue))"], () => { + writer.curlyBlock(["if", "(isRecord(target[key]))"], () => { + writer.lineSM("mergeMatch(target[key] as Record, matchValue)"); + }); + writer.curlyBlock(["else"], () => { + writer.lineSM("target[key] = { ...matchValue }"); + }); + }); + writer.curlyBlock(["else"], () => { + writer.lineSM("target[key] = matchValue"); + }); + }); + }, + ); + writer.line(); + writer.curlyBlock( + [ + "export const", + "applySliceMatch", + "=", + ">(input: T, match: Record): T", + "=>", + ], + () => { + writer.lineSM("const result = { ...input } as Record"); + writer.lineSM("mergeMatch(result, match)"); + writer.lineSM("return result as T"); + }, + ); + writer.line(); + writer.curlyBlock( + ["export const", "matchesValue", "=", "(value: unknown, match: unknown): boolean", "=>"], + () => { + writer.curlyBlock(["if", "(Array.isArray(match))"], () => { + writer.curlyBlock(["if", "(!Array.isArray(value))"], () => writer.lineSM("return false")); + writer.lineSM( + "return match.every((matchItem) => value.some((item) => matchesValue(item, matchItem)))", + ); + }); + writer.curlyBlock(["if", "(isRecord(match))"], () => { + writer.curlyBlock(["if", "(!isRecord(value))"], () => writer.lineSM("return false")); + writer.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { + writer.curlyBlock( + ["if", "(!matchesValue((value as Record)[key], matchValue))"], + () => { + writer.lineSM("return false"); + }, + ); + }); + writer.lineSM("return true"); + }); + writer.lineSM("return value === match"); + }, + ); + writer.line(); + writer.curlyBlock( + ["export const", "matchesSlice", "=", "(value: unknown, match: Record): boolean", "=>"], + () => { + writer.lineSM("return matchesValue(value, match)"); + }, + ); + writer.line(); + // extractComplexExtension - extract sub-extension values from complex extension + writer.curlyBlock( + [ + "export const", + "extractComplexExtension", + "=", + "(extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, config: Array<{ name: string; valueField: string; isArray: boolean }>): Record | undefined", + "=>", + ], + () => { + writer.lineSM("if (!extension?.extension) return undefined"); + writer.lineSM("const result: Record = {}"); + writer.curlyBlock(["for (const", "{ name, valueField, isArray }", "of", "config)"], () => { + writer.lineSM("const subExts = extension.extension.filter(e => e.url === name)"); + writer.curlyBlock(["if", "(isArray)"], () => { + writer.lineSM("result[name] = subExts.map(e => (e as Record)[valueField])"); + }); + writer.curlyBlock(["else if", "(subExts[0])"], () => { + writer.lineSM("result[name] = (subExts[0] as Record)[valueField]"); + }); + }); + writer.lineSM("return result"); + }, + ); + writer.line(); + // extractSliceSimplified - remove match keys from slice (reverse of applySliceMatch) + writer.curlyBlock( + [ + "export const", + "extractSliceSimplified", + "=", + ">(slice: T, matchKeys: string[]): Partial", + "=>", + ], + () => { + writer.lineSM("const result = { ...slice } as Record"); + writer.curlyBlock(["for (const", "key", "of", "matchKeys)"], () => { + writer.lineSM("delete result[key]"); + }); + writer.lineSM("return result as Partial"); + }, + ); + }); +}; + +const generateProfileHelpersImport = ( + writer: TypeScript, + options: { + needsGetOrCreateObjectAtPath: boolean; + needsSliceHelpers: boolean; + needsExtensionExtraction: boolean; + needsSliceExtraction: boolean; + }, +) => { + const imports: string[] = []; + if (options.needsSliceHelpers) { + imports.push("applySliceMatch", "matchesSlice"); + } + if (options.needsGetOrCreateObjectAtPath) { + imports.push("getOrCreateObjectAtPath"); + } + if (options.needsExtensionExtraction) { + imports.push("extractComplexExtension"); + } + if (options.needsSliceExtraction) { + imports.push("extractSliceSimplified"); + } + if (imports.length > 0) { + writer.lineSM(`import { ${imports.join(", ")} } from "../../profile-helpers"`); + } +}; + +const collectTypesFromSlices = (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); + } + } + } +}; + +const collectTypesFromExtensions = ( + tsIndex: TypeSchemaIndex, + flatProfile: ProfileTypeSchema, + addType: (typeId: Identifier) => void, +): boolean => { + let needsExtensionType = false; + + for (const ext of flatProfile.extensions ?? []) { + if (ext.isComplex && ext.subExtensions) { + needsExtensionType = true; + for (const sub of ext.subExtensions) { + if (!sub.valueType) continue; + const resolvedType = tsIndex.resolveByUrl( + flatProfile.identifier.package, + sub.valueType.url as CanonicalUrl, + ); + addType(resolvedType?.identifier ?? sub.valueType); + } + } else if (ext.valueTypes && ext.valueTypes.length === 1) { + needsExtensionType = true; + if (ext.valueTypes[0]) { + const resolvedType = tsIndex.resolveByUrl( + flatProfile.identifier.package, + ext.valueTypes[0].url as CanonicalUrl, + ); + addType(resolvedType?.identifier ?? ext.valueTypes[0]); + } + } else { + needsExtensionType = true; + } + } + + return needsExtensionType; +}; + +const collectTypesFromFieldOverrides = ( + tsIndex: TypeSchemaIndex, + flatProfile: ProfileTypeSchema, + addType: (typeId: Identifier) => void, +) => { + const referenceUrl = "http://hl7.org/fhir/StructureDefinition/Reference" as CanonicalUrl; + const referenceSchema = tsIndex.resolveByUrl(flatProfile.identifier.package, referenceUrl); + const specialization = tsIndex.findLastSpecialization(flatProfile); + + if (!isSpecializationTypeSchema(specialization)) return; + + for (const [fieldName, pField] of Object.entries(flatProfile.fields ?? {})) { + if (!isNotChoiceDeclarationField(pField)) continue; + const sField = specialization.fields?.[fieldName]; + if (!sField || isChoiceDeclarationField(sField)) continue; + + if (pField.reference && sField.reference && pField.reference.length < sField.reference.length) { + if (referenceSchema) addType(referenceSchema.identifier); + } else if (pField.required && !sField.required && pField.type) { + addType(pField.type); + } + } +}; + +export const generateProfileImports = ( + writer: TypeScript, + tsIndex: TypeSchemaIndex, + flatProfile: ProfileTypeSchema, +) => { + const usedTypes = new Map(); + + const getModulePath = (typeId: Identifier): string => { + if (isNestedIdentifier(typeId)) { + const path = canonicalToName(typeId.url, true); + if (path) return `../../${tsFhirPackageDir(typeId.package)}/${pascalCase(path)}`; + } + return `../../${tsModulePath(typeId)}`; + }; + + const addType = (typeId: Identifier) => { + if (typeId.kind === "primitive-type") return; + const tsName = tsResourceName(typeId); + if (!usedTypes.has(tsName)) { + usedTypes.set(tsName, { importPath: getModulePath(typeId), tsName }); + } + }; + + addType(flatProfile.base); + collectTypesFromSlices(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); + + if (needsExtensionType) { + const extensionUrl = "http://hl7.org/fhir/StructureDefinition/Extension" as CanonicalUrl; + const extensionSchema = tsIndex.resolveByUrl(flatProfile.identifier.package, extensionUrl); + if (extensionSchema) addType(extensionSchema.identifier); + } + + const sortedImports = Array.from(usedTypes.values()).sort((a, b) => a.tsName.localeCompare(b.tsName)); + for (const { importPath, tsName } of sortedImports) { + writer.tsImportType(importPath, tsName); + } + if (sortedImports.length > 0) writer.line(); +}; + +export const generateProfileClass = ( + writer: TypeScript, + tsIndex: TypeSchemaIndex, + flatProfile: ProfileTypeSchema, + schema?: TypeSchema, +) => { + const tsBaseResourceName = tsTypeFromIdentifier(flatProfile.base); + const tsProfileName = tsResourceName(flatProfile.identifier); + const profileClassName = tsProfileClassName(flatProfile); + + // Known polymorphic field base names in FHIR (value[x], effective[x], etc.) + // These don't exist as direct properties on TypeScript types + const polymorphicBaseNames = new Set([ + "value", + "effective", + "onset", + "abatement", + "occurrence", + "timing", + "deceased", + "born", + "age", + "medication", + "performed", + "serviced", + "collected", + "item", + "subject", + "bounds", + "amount", + "content", + "product", + "rate", + "dose", + "asNeeded", + ]); + + const sliceDefs = Object.entries(flatProfile.fields ?? {}) + .filter(([_fieldName, field]) => isNotChoiceDeclarationField(field) && field.slicing?.slices) + .flatMap(([fieldName, field]) => { + if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) return []; + const baseType = tsTypeFromIdentifier(field.type); + return Object.entries(field.slicing.slices) + .filter(([_sliceName, slice]) => { + const match = slice.match ?? {}; + return Object.keys(match).length > 0; + }) + .map(([sliceName, slice]) => { + const matchFields = Object.keys(slice.match ?? {}); + const required = slice.required ?? []; + // Filter out fields that are in match or polymorphic base names + const filteredRequired = required.filter( + (name) => !matchFields.includes(name) && !polymorphicBaseNames.has(name), + ); + return { + fieldName, + baseType, + sliceName, + match: slice.match ?? {}, + required, + excluded: slice.excluded ?? [], + array: Boolean(field.array), + // Input is optional when there are no required fields after filtering + inputOptional: filteredRequired.length === 0, + }; + }); + }); + + const extensions = flatProfile.extensions ?? []; + const complexExtensions = extensions.filter((ext) => ext.isComplex && ext.subExtensions); + + for (const ext of complexExtensions) { + const typeName = tsExtensionInputTypeName(tsProfileName, ext.name); + writer.curlyBlock(["export", "type", typeName, "="], () => { + for (const sub of ext.subExtensions ?? []) { + const tsType = sub.valueType ? tsTypeFromIdentifier(sub.valueType) : "unknown"; + const isArray = sub.max === "*"; + const isRequired = sub.min !== undefined && sub.min > 0; + writer.lineSM(`${sub.name}${isRequired ? "" : "?"}: ${tsType}${isArray ? "[]" : ""}`); + } + }); + writer.line(); + } + + if (sliceDefs.length > 0) { + for (const sliceDef of sliceDefs) { + const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); + const matchFields = Object.keys(sliceDef.match); + const allExcluded = [...new Set([...sliceDef.excluded, ...matchFields])]; + 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( + (name) => !matchFields.includes(name) && !polymorphicBaseNames.has(name), + ); + const requiredNames = filteredRequired.map((name) => JSON.stringify(name)); + let typeExpr = sliceDef.baseType; + if (excludedNames.length > 0) { + typeExpr = `Omit<${typeExpr}, ${excludedNames.join(" | ")}>`; + } + if (requiredNames.length > 0) { + typeExpr = `${typeExpr} & Required>`; + } + writer.lineSM(`export type ${typeName} = ${typeExpr}`); + } + writer.line(); + } + + // Determine which helpers are actually needed + const needsSliceHelpers = sliceDefs.length > 0; + const extensionsWithNestedPath = extensions.filter((ext) => { + const targetPath = ext.path.split(".").filter((segment) => segment !== "extension"); + return targetPath.length > 0; + }); + const needsGetOrCreateObjectAtPath = extensionsWithNestedPath.length > 0; + const needsExtensionExtraction = complexExtensions.length > 0; + const needsSliceExtraction = sliceDefs.length > 0; + + if (needsSliceHelpers || needsGetOrCreateObjectAtPath || needsExtensionExtraction || needsSliceExtraction) { + generateProfileHelpersImport(writer, { + needsGetOrCreateObjectAtPath, + needsSliceHelpers, + needsExtensionExtraction, + needsSliceExtraction, + }); + writer.line(); + } + + // Check if we have an override interface (narrowed types) + const hasOverrideInterface = detectFieldOverrides(writer, tsIndex, flatProfile).size > 0; + const factoryInfo = collectProfileFactoryInfo(flatProfile); + + const hasParams = factoryInfo.params.length > 0; + const createArgsTypeName = `${profileClassName}Params`; + const paramSignature = hasParams ? `args: ${createArgsTypeName}` : ""; + const allFields = [ + ...factoryInfo.autoFields.map((f) => ({ name: f.name, value: f.value })), + ...factoryInfo.params.map((p) => ({ name: p.name, value: `args.${p.name}` })), + ]; + + if (hasParams) { + writer.curlyBlock(["export", "type", createArgsTypeName, "="], () => { + for (const p of factoryInfo.params) { + writer.lineSM(`${p.name}: ${p.tsType}`); + } + }); + writer.line(); + } + + if (schema) { + writer.comment("CanonicalURL:", schema.identifier.url, `(pkg: ${packageMetaToFhir(packageMeta(schema))})`); + } + writer.curlyBlock(["export", "class", profileClassName], () => { + writer.line(`private resource: ${tsBaseResourceName}`); + writer.line(); + writer.curlyBlock(["constructor", `(resource: ${tsBaseResourceName})`], () => { + writer.line("this.resource = resource"); + }); + writer.line(); + writer.curlyBlock(["static", "from", `(resource: ${tsBaseResourceName})`, `: ${profileClassName}`], () => { + writer.line(`return new ${profileClassName}(resource)`); + }); + writer.line(); + writer.curlyBlock(["static", "createResource", `(${paramSignature})`, `: ${tsBaseResourceName}`], () => { + writer.curlyBlock([`const resource: ${tsBaseResourceName} =`], () => { + for (const f of allFields) { + writer.line(`${f.name}: ${f.value},`); + } + }, [` as unknown as ${tsBaseResourceName}`]); + writer.line("return resource"); + }); + writer.line(); + writer.curlyBlock(["static", "create", `(${paramSignature})`, `: ${profileClassName}`], () => { + writer.line( + `return ${profileClassName}.from(${profileClassName}.createResource(${hasParams ? "args" : ""}))`, + ); + }); + writer.line(); + // toResource() returns base type (e.g., Patient) + writer.curlyBlock(["toResource", "()", `: ${tsBaseResourceName}`], () => { + writer.line("return this.resource"); + }); + writer.line(); + // Getter and setter methods for required profile fields + for (const p of factoryInfo.params) { + const methodSuffix = uppercaseFirstLetter(p.name); + writer.curlyBlock([`get${methodSuffix}`, "()", `: ${p.tsType} | undefined`], () => { + writer.line(`return this.resource.${p.name} as ${p.tsType} | undefined`); + }); + writer.line(); + writer.curlyBlock([`set${methodSuffix}`, `(value: ${p.tsType})`, ": this"], () => { + writer.line(`(this.resource as any).${p.name} = value`); + writer.line("return this"); + }); + writer.line(); + } + // toProfile() returns casted profile type if override interface exists + if (hasOverrideInterface) { + writer.curlyBlock(["toProfile", "()", `: ${tsProfileName}`], () => { + writer.line(`return this.resource as ${tsProfileName}`); + }); + writer.line(); + } + + const extensionMethods = extensions + .filter((ext) => ext.url) + .map((ext) => ({ + ext, + baseName: tsExtensionMethodName(ext.name), + fallbackName: tsExtensionMethodFallback(ext.name, ext.path), + })); + const sliceMethodBases = sliceDefs.map((slice) => tsSliceMethodName(slice.sliceName)); + const methodCounts = new Map(); + for (const name of [...sliceMethodBases, ...extensionMethods.map((m) => m.baseName)]) { + methodCounts.set(name, (methodCounts.get(name) ?? 0) + 1); + } + const extensionMethodNames = new Map( + extensionMethods.map((entry) => [ + entry.ext, + (methodCounts.get(entry.baseName) ?? 0) > 1 ? entry.fallbackName : entry.baseName, + ]), + ); + const sliceMethodNames = new Map( + sliceDefs.map((slice) => { + const baseName = tsSliceMethodName(slice.sliceName); + const needsFallback = (methodCounts.get(baseName) ?? 0) > 1; + const fallback = tsSliceMethodFallback(slice.fieldName, slice.sliceName); + return [slice, needsFallback ? fallback : baseName]; + }), + ); + + generateExtensionSetterMethods(writer, extensions, extensionMethodNames, tsProfileName); + + for (const sliceDef of sliceDefs) { + const methodName = + sliceMethodNames.get(sliceDef) ?? tsSliceMethodFallback(sliceDef.fieldName, sliceDef.sliceName); + const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); + const matchLiteral = JSON.stringify(sliceDef.match); + 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) + const paramSignature = sliceDef.inputOptional + ? `(input?: ${typeName}): this` + : `(input: ${typeName}): this`; + writer.curlyBlock(["public", methodName, paramSignature], () => { + writer.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"; + writer.line(`const value = applySliceMatch(${inputExpr}, match) as unknown as ${sliceDef.baseType}`); + if (sliceDef.array) { + writer.line(`const list = (${fieldAccess} ??= [])`); + writer.line("const index = list.findIndex((item) => matchesSlice(item, match))"); + writer.line("if (index === -1) {"); + writer.indentBlock(() => { + writer.line("list.push(value)"); + }); + writer.line("} else {"); + writer.indentBlock(() => { + writer.line("list[index] = value"); + }); + writer.line("}"); + } else { + writer.line(`${fieldAccess} = value`); + } + writer.line("return this"); + }); + writer.line(); + } + + // Generate extension getters - two methods per extension: + // 1. get{Name}() - returns flat API (simplified) + // 2. get{Name}Extension() - returns raw FHIR Extension + const generatedGetMethods = new Set(); + + for (const ext of extensions) { + if (!ext.url) continue; + const baseName = uppercaseFirstLetter(safeCamelCase(ext.name)); + const getMethodName = `get${baseName}`; + const getExtensionMethodName = `get${baseName}Extension`; + if (generatedGetMethods.has(getMethodName)) continue; + generatedGetMethods.add(getMethodName); + const valueTypes = ext.valueTypes ?? []; + const targetPath = ext.path.split(".").filter((segment) => segment !== "extension"); + + // Helper to generate the extension lookup code + const generateExtLookup = () => { + if (targetPath.length === 0) { + writer.line(`const ext = this.resource.extension?.find(e => e.url === "${ext.url}")`); + } else { + writer.line( + `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + ); + writer.line( + `const ext = (target.extension as Extension[] | undefined)?.find(e => e.url === "${ext.url}")`, + ); + } + }; + + if (ext.isComplex && ext.subExtensions) { + const inputTypeName = tsExtensionInputTypeName(tsProfileName, ext.name); + // Flat API getter + writer.curlyBlock(["public", getMethodName, `(): ${inputTypeName} | undefined`], () => { + generateExtLookup(); + writer.line("if (!ext) return undefined"); + // Build extraction config + const configItems = (ext.subExtensions ?? []).map((sub) => { + const valueField = sub.valueType ? `value${uppercaseFirstLetter(sub.valueType.name)}` : "value"; + const isArray = sub.max === "*"; + return `{ name: "${sub.url}", valueField: "${valueField}", isArray: ${isArray} }`; + }); + writer.line(`const config = [${configItems.join(", ")}]`); + writer.line( + `return extractComplexExtension(ext as unknown as { extension?: Array<{ url?: string; [key: string]: unknown }> }, config) as ${inputTypeName}`, + ); + }); + writer.line(); + // Raw Extension getter + writer.curlyBlock(["public", getExtensionMethodName, "(): Extension | undefined"], () => { + generateExtLookup(); + writer.line("return ext"); + }); + } else if (valueTypes.length === 1 && valueTypes[0]) { + const firstValueType = valueTypes[0]; + const valueType = tsTypeFromIdentifier(firstValueType); + const valueField = `value${uppercaseFirstLetter(firstValueType.name)}`; + // Flat API getter (cast needed: value field may not exist on Extension in this FHIR version) + writer.curlyBlock(["public", getMethodName, `(): ${valueType} | undefined`], () => { + generateExtLookup(); + writer.line( + `return (ext as Record | undefined)?.${valueField} as ${valueType} | undefined`, + ); + }); + writer.line(); + // Raw Extension getter + writer.curlyBlock(["public", getExtensionMethodName, "(): Extension | undefined"], () => { + generateExtLookup(); + writer.line("return ext"); + }); + } else { + // Generic extension - only raw getter makes sense + writer.curlyBlock(["public", getMethodName, "(): Extension | undefined"], () => { + if (targetPath.length === 0) { + writer.line(`return this.resource.extension?.find(e => e.url === "${ext.url}")`); + } else { + writer.line( + `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + ); + writer.line( + `return (target.extension as Extension[] | undefined)?.find(e => e.url === "${ext.url}")`, + ); + } + }); + } + writer.line(); + } + + // Generate slice getters - two methods per slice: + // 1. get{SliceName}() - returns simplified (without discriminator fields) + // 2. get{SliceName}Raw() - returns full FHIR type with all fields + for (const sliceDef of sliceDefs) { + const baseName = uppercaseFirstLetter(safeCamelCase(sliceDef.sliceName)); + const getMethodName = `get${baseName}`; + const getRawMethodName = `get${baseName}Raw`; + if (generatedGetMethods.has(getMethodName)) continue; + generatedGetMethods.add(getMethodName); + const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); + const matchLiteral = JSON.stringify(sliceDef.match); + const matchKeys = JSON.stringify(Object.keys(sliceDef.match)); + const tsField = tsFieldName(sliceDef.fieldName); + const fieldAccess = tsGet("this.resource", tsField); + const baseType = sliceDef.baseType; + + // Helper to find the slice item + const generateSliceLookup = () => { + writer.line(`const match = ${matchLiteral} as Record`); + if (sliceDef.array) { + writer.line(`const list = ${fieldAccess}`); + writer.line("if (!list) return undefined"); + writer.line("const item = list.find((item) => matchesSlice(item, match))"); + } else { + writer.line(`const item = ${fieldAccess}`); + writer.line("if (!item || !matchesSlice(item, match)) return undefined"); + } + }; + + // Flat API getter (simplified) + writer.curlyBlock(["public", getMethodName, `(): ${typeName} | undefined`], () => { + generateSliceLookup(); + if (sliceDef.array) { + writer.line("if (!item) return undefined"); + } + writer.line( + `return extractSliceSimplified(item as unknown as Record, ${matchKeys}) as ${typeName}`, + ); + }); + writer.line(); + + // Raw getter (full FHIR type) + writer.curlyBlock(["public", getRawMethodName, `(): ${baseType} | undefined`], () => { + generateSliceLookup(); + if (sliceDef.array) { + writer.line("return item"); + } else { + writer.line("return item"); + } + }); + writer.line(); + } + }); + writer.line(); +}; + +const generateExtensionSetterMethods = ( + writer: TypeScript, + extensions: ProfileExtension[], + extensionMethodNames: Map, + tsProfileName: string, +) => { + for (const ext of extensions) { + if (!ext.url) continue; + const methodName = extensionMethodNames.get(ext) ?? tsExtensionMethodFallback(ext.name, ext.path); + const valueTypes = ext.valueTypes ?? []; + const targetPath = ext.path.split(".").filter((segment) => segment !== "extension"); + + if (ext.isComplex && ext.subExtensions) { + const inputTypeName = tsExtensionInputTypeName(tsProfileName, ext.name); + writer.curlyBlock(["public", methodName, `(input: ${inputTypeName}): this`], () => { + writer.line("const subExtensions: Extension[] = []"); + for (const sub of ext.subExtensions ?? []) { + const valueField = sub.valueType ? `value${uppercaseFirstLetter(sub.valueType.name)}` : "value"; + // When value type is unknown, cast to Extension to avoid TS error + const needsCast = !sub.valueType; + const pushSuffix = needsCast ? " as Extension" : ""; + if (sub.max === "*") { + writer.curlyBlock(["if", `(input.${sub.name})`], () => { + writer.curlyBlock(["for", `(const item of input.${sub.name})`], () => { + writer.line( + `subExtensions.push({ url: "${sub.url}", ${valueField}: item }${pushSuffix})`, + ); + }); + }); + } else { + writer.curlyBlock(["if", `(input.${sub.name} !== undefined)`], () => { + writer.line( + `subExtensions.push({ url: "${sub.url}", ${valueField}: input.${sub.name} }${pushSuffix})`, + ); + }); + } + } + if (targetPath.length === 0) { + writer.line("const list = (this.resource.extension ??= [])"); + writer.line(`list.push({ url: "${ext.url}", extension: subExtensions })`); + } else { + writer.line( + `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + ); + writer.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); + writer.line( + `(target.extension as Extension[]).push({ url: "${ext.url}", extension: subExtensions })`, + ); + } + writer.line("return this"); + }); + } else if (valueTypes.length === 1 && valueTypes[0]) { + const firstValueType = valueTypes[0]; + const valueType = tsTypeFromIdentifier(firstValueType); + const valueField = `value${uppercaseFirstLetter(firstValueType.name)}`; + writer.curlyBlock(["public", methodName, `(value: ${valueType}): this`], () => { + // Cast needed: value field may not exist on Extension in this FHIR version + const extLiteral = `{ url: "${ext.url}", ${valueField}: value } as Extension`; + if (targetPath.length === 0) { + writer.line("const list = (this.resource.extension ??= [])"); + writer.line(`list.push(${extLiteral})`); + } else { + writer.line( + `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( + targetPath, + )})`, + ); + writer.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); + writer.line(`(target.extension as Extension[]).push(${extLiteral})`); + } + writer.line("return this"); + }); + } else { + writer.curlyBlock(["public", methodName, `(value: Omit): this`], () => { + if (targetPath.length === 0) { + writer.line("const list = (this.resource.extension ??= [])"); + writer.line(`list.push({ url: "${ext.url}", ...value })`); + } else { + writer.line( + `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( + targetPath, + )})`, + ); + writer.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); + writer.line(`(target.extension as Extension[]).push({ url: "${ext.url}", ...value })`); + } + writer.line("return this"); + }); + } + writer.line(); + } +}; + +export const detectFieldOverrides = ( + writer: TypeScript, + tsIndex: TypeSchemaIndex, + flatProfile: ProfileTypeSchema, +): Map => { + const overrides = new Map(); + const specialization = tsIndex.findLastSpecialization(flatProfile); + if (!isSpecializationTypeSchema(specialization)) return overrides; + + for (const [fieldName, pField] of Object.entries(flatProfile.fields ?? {})) { + if (!isNotChoiceDeclarationField(pField)) continue; + const sField = specialization.fields?.[fieldName]; + if (!sField || isChoiceDeclarationField(sField)) continue; + + // Check for Reference narrowing + if (pField.reference && sField.reference && pField.reference.length < sField.reference.length) { + const references = pField.reference + .map((ref) => { + const resRef = tsIndex.findLastSpecializationByIdentifier(ref); + if (resRef.name !== ref.name) { + return `"${resRef.name}"`; + } + return `"${ref.name}"`; + }) + .join(" | "); + overrides.set(fieldName, { + profileType: `Reference<${references}>`, + required: pField.required ?? false, + array: pField.array ?? false, + }); + } + // Check for cardinality change (optional -> required) + else if (pField.required && !sField.required) { + const tsType = tsTypeForProfileField(writer, tsIndex, flatProfile, fieldName, pField); + overrides.set(fieldName, { + profileType: tsType, + required: true, + array: pField.array ?? false, + }); + } + } + return overrides; +}; + +export const generateProfileOverrideInterface = ( + writer: TypeScript, + tsIndex: TypeSchemaIndex, + flatProfile: ProfileTypeSchema, +) => { + const overrides = detectFieldOverrides(writer, tsIndex, flatProfile); + if (overrides.size === 0) return; + + const tsProfileName = tsResourceName(flatProfile.identifier); + const tsBaseResourceName = tsResourceName(flatProfile.base); + + writer.curlyBlock(["export", "interface", tsProfileName, "extends", tsBaseResourceName], () => { + for (const [fieldName, override] of overrides) { + const tsField = tsFieldName(fieldName); + const optionalSymbol = override.required ? "" : "?"; + const arraySymbol = override.array ? "[]" : ""; + writer.lineSM(`${tsField}${optionalSymbol}: ${override.profileType}${arraySymbol}`); + } + }); + writer.line(); +}; diff --git a/src/api/writer-generator/typescript/utils.ts b/src/api/writer-generator/typescript/utils.ts new file mode 100644 index 00000000..07b0529b --- /dev/null +++ b/src/api/writer-generator/typescript/utils.ts @@ -0,0 +1,159 @@ +import { + camelCase, + kebabCase, + uppercaseFirstLetter, + uppercaseFirstLetterOfEach, +} from "@root/api/writer-generator/utils"; +import { + type CanonicalUrl, + type ChoiceFieldInstance, + type EnumDefinition, + extractNameFromCanonical, + type Identifier, + isNestedIdentifier, + isPrimitiveIdentifier, + type RegularField, +} from "@root/typeschema/types"; + +export const primitiveType2tsType: Record = { + boolean: "boolean", + instant: "string", + time: "string", + date: "string", + dateTime: "string", + + decimal: "number", + integer: "number", + unsignedInt: "number", + positiveInt: "number", + integer64: "number", + base64Binary: "string", + + uri: "string", + url: "string", + canonical: "string", + oid: "string", + uuid: "string", + + string: "string", + code: "string", + markdown: "string", + id: "string", + xhtml: "string", +}; + +export const resolvePrimitiveType = (name: string) => { + const tsType = primitiveType2tsType[name]; + if (tsType === undefined) throw new Error(`Unknown primitive type ${name}`); + return tsType; +}; + +export const tsFhirPackageDir = (name: string): string => { + return kebabCase(name); +}; + +export const tsModuleName = (id: Identifier): string => { + // NOTE: Why not pascal case? + // In hl7-fhir-uv-xver-r5-r4 we have: + // - http://hl7.org/fhir/5.0/StructureDefinition/extension-Subscription.topic (subscription_topic) + // - http://hl7.org/fhir/5.0/StructureDefinition/extension-SubscriptionTopic (SubscriptionTopic) + // And they should not clash the names. + return uppercaseFirstLetter(normalizeTsName(id.name)); +}; + +export const tsModuleFileName = (id: Identifier): string => { + return `${tsModuleName(id)}.ts`; +}; + +export const tsModulePath = (id: Identifier): string => { + return `${tsFhirPackageDir(id.package)}/${tsModuleName(id)}`; +}; + +export const canonicalToName = (canonical: string | undefined, dropFragment = true) => { + if (!canonical) return undefined; + const localName = extractNameFromCanonical(canonical as CanonicalUrl, dropFragment); + if (!localName) return undefined; + return normalizeTsName(localName); +}; + +export const tsResourceName = (id: Identifier): string => { + if (id.kind === "nested") { + const url = id.url; + // Extract name from URL without normalizing dots (needed for fragment splitting) + const localName = extractNameFromCanonical(url as CanonicalUrl, false); + if (!localName) return ""; + const [resourceName, fragment] = localName.split("#"); + const name = uppercaseFirstLetterOfEach((fragment ?? "").split(".")).join(""); + return normalizeTsName([resourceName, name].join("")); + } + return normalizeTsName(id.name); +}; + +// biome-ignore format: too long +export const tsKeywords = new Set([ "class", "function", "return", "if", "for", "while", "const", "let", "var", "import", "export", "interface" ]); + +export const tsFieldName = (n: string): string => { + if (tsKeywords.has(n)) return `"${n}"`; + if (n.includes(" ") || n.includes("-")) return `"${n}"`; + return n; +}; + +export const normalizeTsName = (n: string): string => { + if (tsKeywords.has(n)) n = `${n}_`; + return n.replace(/\[x\]/g, "_x_").replace(/[- :.]/g, "_"); +}; + +export const tsGet = (object: string, tsFieldName: string) => { + if (tsFieldName.startsWith('"')) return `${object}[${tsFieldName}]`; + return `${object}.${tsFieldName}`; +}; + +export const tsEnumType = (enumDef: EnumDefinition) => { + const values = enumDef.values.map((e) => `"${e}"`).join(" | "); + return enumDef.isOpen ? `(${values} | string)` : `(${values})`; +}; + +export const rewriteFieldTypeDefs: Record string>> = { + Coding: { code: () => "T" }, + // biome-ignore lint: that is exactly string what we want + Reference: { reference: () => "`${T}/${string}`" }, + CodeableConcept: { coding: () => "Coding" }, +}; + +export const resolveFieldTsType = ( + schemaName: string, + tsName: string, + field: RegularField | ChoiceFieldInstance, +): string => { + const rewriteFieldType = rewriteFieldTypeDefs[schemaName]?.[tsName]; + if (rewriteFieldType) return rewriteFieldType(); + + if (field.enum) { + if (field.type.name === "Coding") return `Coding<${tsEnumType(field.enum)}>`; + if (field.type.name === "CodeableConcept") return `CodeableConcept<${tsEnumType(field.enum)}>`; + return tsEnumType(field.enum); + } + if (field.reference && field.reference.length > 0) { + const references = field.reference.map((ref) => `"${ref.name}"`).join(" | "); + return `Reference<${references}>`; + } + if (isPrimitiveIdentifier(field.type)) return resolvePrimitiveType(field.type.name); + if (isNestedIdentifier(field.type)) return tsResourceName(field.type); + return field.type.name as string; +}; + +export const tsTypeFromIdentifier = (id: Identifier): string => { + if (isNestedIdentifier(id)) return tsResourceName(id); + if (isPrimitiveIdentifier(id)) return resolvePrimitiveType(id.name); + // Fallback: check if id.name is a known primitive type even if kind isn't set + const primitiveType = primitiveType2tsType[id.name]; + if (primitiveType !== undefined) return primitiveType; + return id.name; +}; + +export const safeCamelCase = (name: string): string => { + if (!name) return ""; + // Remove [x] suffix and normalize special characters before camelCase + const normalized = name.replace(/\[x\]/g, "").replace(/:/g, "_"); + return camelCase(normalized); +}; diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts index 15464415..c3c15068 100644 --- a/src/api/writer-generator/typescript/writer.ts +++ b/src/api/writer-generator/typescript/writer.ts @@ -1,275 +1,40 @@ -import { - camelCase, - kebabCase, - pascalCase, - typeSchemaInfo, - uppercaseFirstLetter, - uppercaseFirstLetterOfEach, -} from "@root/api/writer-generator/utils"; +import { uppercaseFirstLetter } from "@root/api/writer-generator/utils"; import { Writer, type WriterOptions } from "@root/api/writer-generator/writer"; import { type CanonicalUrl, - type ChoiceFieldInstance, - type EnumDefinition, - extractNameFromCanonical, - type Identifier, isChoiceDeclarationField, - isChoiceInstanceField, isComplexTypeIdentifier, isLogicalTypeSchema, isNestedIdentifier, - isNotChoiceDeclarationField, isPrimitiveIdentifier, isProfileTypeSchema, - isResourceIdentifier, isResourceTypeSchema, isSpecializationTypeSchema, type Name, - type ProfileExtension, - type ProfileTypeSchema, packageMeta, packageMetaToFhir, - type RegularField, type RegularTypeSchema, type TypeSchema, } from "@root/typeschema/types"; import { groupByPackages, type TypeSchemaIndex } from "@root/typeschema/utils"; - -const primitiveType2tsType: Record = { - boolean: "boolean", - instant: "string", - time: "string", - date: "string", - dateTime: "string", - - decimal: "number", - integer: "number", - unsignedInt: "number", - positiveInt: "number", - integer64: "number", - base64Binary: "string", - - uri: "string", - url: "string", - canonical: "string", - oid: "string", - uuid: "string", - - string: "string", - code: "string", - markdown: "string", - id: "string", - xhtml: "string", -}; - -const resolvePrimitiveType = (name: string) => { - const tsType = primitiveType2tsType[name]; - if (tsType === undefined) throw new Error(`Unknown primitive type ${name}`); - return tsType; -}; - -const tsFhirPackageDir = (name: string): string => { - return kebabCase(name); -}; - -const tsModuleName = (id: Identifier): string => { - // NOTE: Why not pascal case? - // In hl7-fhir-uv-xver-r5-r4 we have: - // - http://hl7.org/fhir/5.0/StructureDefinition/extension-Subscription.topic (subscription_topic) - // - http://hl7.org/fhir/5.0/StructureDefinition/extension-SubscriptionTopic (SubscriptionTopic) - // And they should not clash the names. - return uppercaseFirstLetter(normalizeTsName(id.name)); -}; - -const tsProfileModuleName = (tsIndex: TypeSchemaIndex, schema: ProfileTypeSchema): string => { - const resourceSchema = tsIndex.findLastSpecialization(schema); - const resourceName = uppercaseFirstLetter(normalizeTsName(resourceSchema.identifier.name)); - return `${resourceName}_${normalizeTsName(schema.identifier.name)}`; -}; - -const tsModuleFileName = (id: Identifier): string => { - return `${tsModuleName(id)}.ts`; -}; - -const tsProfileModuleFileName = (tsIndex: TypeSchemaIndex, schema: ProfileTypeSchema): string => { - return `${tsProfileModuleName(tsIndex, schema)}.ts`; -}; - -const tsModulePath = (id: Identifier): string => { - return `${tsFhirPackageDir(id.package)}/${tsModuleName(id)}`; -}; - -const canonicalToName = (canonical: string | undefined, dropFragment = true) => { - if (!canonical) return undefined; - const localName = extractNameFromCanonical(canonical as CanonicalUrl, dropFragment); - if (!localName) return undefined; - return normalizeTsName(localName); -}; - -const tsResourceName = (id: Identifier): string => { - if (id.kind === "nested") { - const url = id.url; - // Extract name from URL without normalizing dots (needed for fragment splitting) - const localName = extractNameFromCanonical(url as CanonicalUrl, false); - if (!localName) return ""; - const [resourceName, fragment] = localName.split("#"); - const name = uppercaseFirstLetterOfEach((fragment ?? "").split(".")).join(""); - return normalizeTsName([resourceName, name].join("")); - } - return normalizeTsName(id.name); -}; - -// biome-ignore format: too long -const tsKeywords = new Set([ "class", "function", "return", "if", "for", "while", "const", "let", "var", "import", "export", "interface" ]); - -const tsFieldName = (n: string): string => { - if (tsKeywords.has(n)) return `"${n}"`; - if (n.includes(" ") || n.includes("-")) return `"${n}"`; - return n; -}; - -const normalizeTsName = (n: string): string => { - if (tsKeywords.has(n)) n = `${n}_`; - return n.replace(/\[x\]/g, "_x_").replace(/[- :.]/g, "_"); -}; - -const tsGet = (object: string, tsFieldName: string) => { - if (tsFieldName.startsWith('"')) return `${object}[${tsFieldName}]`; - return `${object}.${tsFieldName}`; -}; - -const tsEnumType = (enumDef: EnumDefinition) => { - const values = enumDef.values.map((e) => `"${e}"`).join(" | "); - return enumDef.isOpen ? `(${values} | string)` : `(${values})`; -}; - -const rewriteFieldTypeDefs: Record string>> = { - Coding: { code: () => "T" }, - // biome-ignore lint: that is exactly string what we want - Reference: { reference: () => "`${T}/${string}`" }, - CodeableConcept: { coding: () => "Coding" }, -}; - -const resolveFieldTsType = (schemaName: string, tsName: string, field: RegularField | ChoiceFieldInstance): string => { - const rewriteFieldType = rewriteFieldTypeDefs[schemaName]?.[tsName]; - if (rewriteFieldType) return rewriteFieldType(); - - if (field.enum) { - if (field.type.name === "Coding") return `Coding<${tsEnumType(field.enum)}>`; - if (field.type.name === "CodeableConcept") return `CodeableConcept<${tsEnumType(field.enum)}>`; - return tsEnumType(field.enum); - } - if (field.reference && field.reference.length > 0) { - const references = field.reference.map((ref) => `"${ref.name}"`).join(" | "); - return `Reference<${references}>`; - } - if (isPrimitiveIdentifier(field.type)) return resolvePrimitiveType(field.type.name); - if (isNestedIdentifier(field.type)) return tsResourceName(field.type); - return field.type.name as string; -}; - -const tsTypeFromIdentifier = (id: Identifier): string => { - if (isNestedIdentifier(id)) return tsResourceName(id); - if (isPrimitiveIdentifier(id)) return resolvePrimitiveType(id.name); - // Fallback: check if id.name is a known primitive type even if kind isn't set - const primitiveType = primitiveType2tsType[id.name]; - if (primitiveType !== undefined) return primitiveType; - return id.name; -}; - -const tsProfileClassName = (schema: ProfileTypeSchema): string => { - return `${normalizeTsName(schema.identifier.name)}Profile`; -}; - -type ProfileFactoryInfo = { - autoFields: { name: string; value: string }[]; - params: { name: string; tsType: string; typeId: Identifier }[]; -}; - -const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFactoryInfo => { - const autoFields: ProfileFactoryInfo["autoFields"] = []; - const params: ProfileFactoryInfo["params"] = []; - const fields = flatProfile.fields ?? {}; - - if (isResourceIdentifier(flatProfile.base)) { - autoFields.push({ name: "resourceType", value: JSON.stringify(flatProfile.base.name) }); - } - - for (const [name, field] of Object.entries(fields)) { - if (isChoiceInstanceField(field)) continue; - if (field.excluded) continue; - - // Required choice declaration with a single choice — promote that choice to a param - if (isChoiceDeclarationField(field)) { - if (field.required && field.choices.length === 1) { - const choiceName = field.choices[0]; - if (choiceName) { - const choiceField = fields[choiceName]; - if (choiceField && isChoiceInstanceField(choiceField)) { - const tsType = tsTypeFromIdentifier(choiceField.type) + (choiceField.array ? "[]" : ""); - params.push({ name: choiceName, tsType, typeId: choiceField.type }); - } - } - } - continue; - } - - if (field.valueConstraint) { - const value = JSON.stringify(field.valueConstraint.value); - autoFields.push({ name, value: field.array ? `[${value}]` : value }); - continue; - } - - if (field.required) { - const tsType = resolveFieldTsType("", "", field) + (field.array ? "[]" : ""); - params.push({ name, tsType, typeId: field.type }); - } - } - - return { autoFields, params }; -}; - -const tsSliceInputTypeName = (profileName: string, fieldName: string, sliceName: string): string => { - return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(fieldName))}_${uppercaseFirstLetter(normalizeTsName(sliceName))}SliceInput`; -}; - -const tsExtensionInputTypeName = (profileName: string, extensionName: string): string => { - return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(extensionName))}Input`; -}; - -const safeCamelCase = (name: string): string => { - if (!name) return ""; - // Remove [x] suffix and normalize special characters before camelCase - const normalized = name.replace(/\[x\]/g, "").replace(/:/g, "_"); - return camelCase(normalized); -}; - -const tsSliceMethodName = (sliceName: string): string => { - const normalized = safeCamelCase(sliceName); - return `set${uppercaseFirstLetter(normalized || "Slice")}`; -}; - -const tsExtensionMethodName = (name: string): string => { - const normalized = safeCamelCase(name); - return `set${uppercaseFirstLetter(normalized || "Extension")}`; -}; - -const tsExtensionMethodFallback = (name: string, path?: string): string => { - const rawPath = - path - ?.split(".") - .filter((p) => p && p !== "extension") - .join("_") ?? ""; - const pathPart = rawPath ? uppercaseFirstLetter(safeCamelCase(rawPath)) : ""; - const normalized = safeCamelCase(name); - return `setExtension${pathPart}${uppercaseFirstLetter(normalized || "Extension")}`; -}; - -const tsSliceMethodFallback = (fieldName: string, sliceName: string): string => { - const fieldPart = uppercaseFirstLetter(safeCamelCase(fieldName) || "Field"); - const slicePart = uppercaseFirstLetter(safeCamelCase(sliceName) || "Slice"); - return `setSlice${fieldPart}${slicePart}`; -}; +import { + generateProfileClass, + generateProfileHelpersModule, + generateProfileImports, + generateProfileIndexFile, + generateProfileOverrideInterface, + tsProfileModuleFileName, +} from "./profile"; +import { + canonicalToName, + resolveFieldTsType, + tsFhirPackageDir, + tsFieldName, + tsModuleFileName, + tsModuleName, + tsModulePath, + tsResourceName, +} from "./utils"; export type TypeScriptOptions = { /** openResourceTypeSet -- for resource families (Resource, DomainResource) use open set for resourceType field. @@ -286,38 +51,6 @@ export class TypeScript extends Writer { this.lineSM(`import type { ${entities.join(", ")} } from "${tsPackageName}"`); } - private generateProfileIndexFile(tsIndex: TypeSchemaIndex, initialProfiles: ProfileTypeSchema[]) { - if (initialProfiles.length === 0) return; - this.cd("profiles", () => { - this.cat("index.ts", () => { - const profiles: [ProfileTypeSchema, string, string | undefined][] = initialProfiles.map((profile) => { - const className = tsProfileClassName(profile); - const resourceName = tsResourceName(profile.identifier); - const overrides = this.detectFieldOverrides(tsIndex, profile); - let typeExport; - if (overrides.size > 0) typeExport = resourceName; - return [profile, className, typeExport]; - }); - if (profiles.length === 0) return; - const classExports: Map = new Map(); - const typeExports: Map = new Map(); - for (const [profile, className, typeName] of profiles) { - const moduleName = tsProfileModuleName(tsIndex, profile); - if (!classExports.has(className)) { - classExports.set(className, `export { ${className} } from "./${moduleName}"`); - } - if (typeName && !typeExports.has(typeName)) { - typeExports.set(typeName, `export type { ${typeName} } from "./${moduleName}"`); - } - } - const allExports = [...classExports.values(), ...typeExports.values()].sort(); - for (const exp of allExports) { - this.lineSM(exp); - } - }); - }); - } - generateFhirPackageIndexFile(schemas: TypeSchema[]) { this.cat("index.ts", () => { const profiles = schemas.filter(isProfileTypeSchema); @@ -522,1082 +255,15 @@ export class TypeScript extends Writer { } } - private tsTypeForProfileField( - tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, - fieldName: string, - field: NonNullable[string], - ): string { - if (!isNotChoiceDeclarationField(field)) { - throw new Error(`Choice declaration fields not supported for '${fieldName}'`); - } - - let tsType: string; - if (field.enum) { - if (field.type?.name === "Coding") { - tsType = `Coding<${tsEnumType(field.enum)}>`; - } else if (field.type?.name === "CodeableConcept") { - tsType = `CodeableConcept<${tsEnumType(field.enum)}>`; - } else { - tsType = tsEnumType(field.enum); - } - } else if (field.reference && field.reference.length > 0) { - const specialization = tsIndex.findLastSpecialization(flatProfile); - if (!isSpecializationTypeSchema(specialization)) - throw new Error(`Invalid specialization for ${flatProfile.identifier}`); - - const sField = specialization.fields?.[fieldName]; - if (sField === undefined || isChoiceDeclarationField(sField) || sField.reference === undefined) - throw new Error(`Invalid field declaration for ${fieldName}`); - - const sRefs = sField.reference.map((e) => e.name); - const references = field.reference - .map((ref) => { - const resRef = tsIndex.findLastSpecializationByIdentifier(ref); - if (resRef.name !== ref.name) { - return `"${resRef.name}" /*${ref.name}*/`; - } - return `'${ref.name}'`; - }) - .join(" | "); - if (sRefs.length === 1 && sRefs[0] === "Resource" && references !== '"Resource"') { - // FIXME: should be generilized to type families - // Strip inner comments to avoid nested /* */ which is invalid - const cleanRefs = references.replace(/\/\*[^*]*\*\//g, "").trim(); - tsType = `Reference<"Resource" /* ${cleanRefs} */ >`; - } else { - tsType = `Reference<${references}>`; - } - } else if (isPrimitiveIdentifier(field.type)) { - tsType = resolvePrimitiveType(field.type.name); - } else if (isNestedIdentifier(field.type)) { - tsType = tsResourceName(field.type); - } else if (field.type === undefined) { - throw new Error(`Undefined type for '${fieldName}' field at ${typeSchemaInfo(flatProfile)}`); - } else { - tsType = field.type.name; - } - - return tsType; - } - - generateProfileType(tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) { - this.debugComment("flatProfile", flatProfile); - const tsName = tsResourceName(flatProfile.identifier); - this.debugComment("identifier", flatProfile.identifier); - this.debugComment("base", flatProfile.base); - this.curlyBlock(["export", "interface", tsName], () => { - this.lineSM(`__profileUrl: "${flatProfile.identifier.url}"`); - this.line(); - - for (const [fieldName, field] of Object.entries(flatProfile.fields ?? {})) { - if (isChoiceDeclarationField(field)) continue; - this.debugComment(fieldName, field); - - const tsName = tsFieldName(fieldName); - const tsType = this.tsTypeForProfileField(tsIndex, flatProfile, fieldName, field); - this.lineSM(`${tsName}${!field.required ? "?" : ""}: ${tsType}${field.array ? "[]" : ""}`); - } - }); - - this.line(); - } - - generateAttachProfile(flatProfile: ProfileTypeSchema) { - const tsBaseResourceName = tsResourceName(flatProfile.base); - const tsProfileName = tsResourceName(flatProfile.identifier); - const profileFields = Object.entries(flatProfile.fields || {}) - .filter(([_fieldName, field]) => { - return field && isNotChoiceDeclarationField(field) && field.type !== undefined; - }) - .map(([fieldName]) => tsFieldName(fieldName)); - - this.curlyBlock( - [ - `export const attach_${tsProfileName}_to_${tsBaseResourceName} =`, - `(resource: ${tsBaseResourceName}, profile: ${tsProfileName}): ${tsBaseResourceName}`, - "=>", - ], - () => { - this.curlyBlock(["return"], () => { - this.line("...resource,"); - // FIXME: don't rewrite all profiles - this.curlyBlock(["meta:"], () => { - this.line(`profile: ['${flatProfile.identifier.url}']`); - }, [","]); - profileFields.forEach((fieldName) => { - this.line(`${fieldName}: ${tsGet("profile", fieldName)},`); - }); - }); - }, - ); - this.line(); - } - - generateExtractProfile(tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) { - const tsBaseResourceName = tsResourceName(flatProfile.base); - const tsProfileName = tsResourceName(flatProfile.identifier); - - const profileFields = Object.entries(flatProfile.fields || {}) - .filter(([_fieldName, field]) => { - return isNotChoiceDeclarationField(field) && field.type !== undefined; - }) - .map(([fieldName]) => fieldName); - - const specialization = tsIndex.findLastSpecialization(flatProfile); - if (!isSpecializationTypeSchema(specialization)) - throw new Error(`Specialization not found for ${flatProfile.identifier.url}`); - - const shouldCast: Record = {}; - this.curlyBlock( - [ - `export const extract_${tsProfileName}_from_${tsBaseResourceName} =`, - `(resource: ${tsBaseResourceName}): ${tsProfileName}`, - "=>", - ], - () => { - profileFields.forEach((fieldName) => { - const tsField = tsFieldName(fieldName); - const pField = flatProfile.fields?.[fieldName]; - const rField = specialization.fields?.[fieldName]; - if (!isNotChoiceDeclarationField(pField) || !isNotChoiceDeclarationField(rField)) return; - - if (pField.required && !rField.required) { - this.curlyBlock([`if (${tsGet("resource", tsField)} === undefined)`], () => - this.lineSM( - `throw new Error("'${tsField}' is required for ${flatProfile.identifier.url}")`, - ), - ); - } - - const pRefs = pField?.reference?.map((ref) => ref.name); - const rRefs = rField?.reference?.map((ref) => ref.name); - if (pRefs && rRefs && pRefs.length !== rRefs.length) { - const predName = `reference_is_valid_${tsField}`; - this.curlyBlock(["const", predName, "=", "(ref?: Reference)", "=>"], () => { - this.line("return !ref"); - this.indentBlock(() => { - rRefs.forEach((ref) => { - this.line(`|| ref.reference?.startsWith('${ref}/')`); - }); - this.line(";"); - }); - }); - let cond: string = !pField?.required ? `!${tsGet("resource", tsField)} || ` : ""; - if (pField.array) { - cond += `${tsGet("resource", tsField)}.every( (ref) => ${predName}(ref) )`; - } else { - cond += `!${predName}(${tsGet("resource", tsField)})`; - } - this.curlyBlock(["if (", cond, ")"], () => { - this.lineSM( - `throw new Error("'${fieldName}' has different references in profile and specialization")`, - ); - }); - this.line(); - shouldCast[fieldName] = true; - } - }); - this.curlyBlock(["return"], () => { - this.line(`__profileUrl: '${flatProfile.identifier.url}',`); - profileFields.forEach((fieldName) => { - const tsField = tsFieldName(fieldName); - if (shouldCast[fieldName]) { - this.line( - `${tsField}:`, - `${tsGet("resource", tsField)} as ${tsProfileName}['${tsField}'],`, - ); - } else { - this.line(`${tsField}:`, `${tsGet("resource", tsField)},`); - } - }); - }); - }, - ); - } - - generateProfileHelpersModule() { - this.cat("profile-helpers.ts", () => { - this.generateDisclaimer(); - this.curlyBlock( - ["export const", "isRecord", "=", "(value: unknown): value is Record", "=>"], - () => { - this.lineSM('return value !== null && typeof value === "object" && !Array.isArray(value)'); - }, - ); - this.line(); - this.curlyBlock( - [ - "export const", - "getOrCreateObjectAtPath", - "=", - "(root: Record, path: string[]): Record", - "=>", - ], - () => { - this.lineSM("let current: Record = root"); - this.curlyBlock(["for (const", "segment", "of", "path)"], () => { - this.curlyBlock(["if", "(Array.isArray(current[segment]))"], () => { - this.lineSM("const list = current[segment] as unknown[]"); - this.curlyBlock(["if", "(list.length === 0)"], () => { - this.lineSM("list.push({})"); - }); - this.lineSM("current = list[0] as Record"); - }); - this.curlyBlock(["else"], () => { - this.curlyBlock(["if", "(!isRecord(current[segment]))"], () => { - this.lineSM("current[segment] = {}"); - }); - this.lineSM("current = current[segment] as Record"); - }); - }); - this.lineSM("return current"); - }, - ); - this.line(); - this.curlyBlock( - [ - "export const", - "mergeMatch", - "=", - "(target: Record, match: Record): void", - "=>", - ], - () => { - this.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { - this.curlyBlock( - ["if", '(key === "__proto__" || key === "constructor" || key === "prototype")'], - () => { - this.lineSM("continue"); - }, - ); - this.curlyBlock(["if", "(isRecord(matchValue))"], () => { - this.curlyBlock(["if", "(isRecord(target[key]))"], () => { - this.lineSM("mergeMatch(target[key] as Record, matchValue)"); - }); - this.curlyBlock(["else"], () => { - this.lineSM("target[key] = { ...matchValue }"); - }); - }); - this.curlyBlock(["else"], () => { - this.lineSM("target[key] = matchValue"); - }); - }); - }, - ); - this.line(); - this.curlyBlock( - [ - "export const", - "applySliceMatch", - "=", - ">(input: T, match: Record): T", - "=>", - ], - () => { - this.lineSM("const result = { ...input } as Record"); - this.lineSM("mergeMatch(result, match)"); - this.lineSM("return result as T"); - }, - ); - this.line(); - this.curlyBlock( - ["export const", "matchesValue", "=", "(value: unknown, match: unknown): boolean", "=>"], - () => { - this.curlyBlock(["if", "(Array.isArray(match))"], () => { - this.curlyBlock(["if", "(!Array.isArray(value))"], () => this.lineSM("return false")); - this.lineSM( - "return match.every((matchItem) => value.some((item) => matchesValue(item, matchItem)))", - ); - }); - this.curlyBlock(["if", "(isRecord(match))"], () => { - this.curlyBlock(["if", "(!isRecord(value))"], () => this.lineSM("return false")); - this.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { - this.curlyBlock( - ["if", "(!matchesValue((value as Record)[key], matchValue))"], - () => { - this.lineSM("return false"); - }, - ); - }); - this.lineSM("return true"); - }); - this.lineSM("return value === match"); - }, - ); - this.line(); - this.curlyBlock( - [ - "export const", - "matchesSlice", - "=", - "(value: unknown, match: Record): boolean", - "=>", - ], - () => { - this.lineSM("return matchesValue(value, match)"); - }, - ); - this.line(); - // extractComplexExtension - extract sub-extension values from complex extension - this.curlyBlock( - [ - "export const", - "extractComplexExtension", - "=", - "(extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, config: Array<{ name: string; valueField: string; isArray: boolean }>): Record | undefined", - "=>", - ], - () => { - this.lineSM("if (!extension?.extension) return undefined"); - this.lineSM("const result: Record = {}"); - this.curlyBlock(["for (const", "{ name, valueField, isArray }", "of", "config)"], () => { - this.lineSM("const subExts = extension.extension.filter(e => e.url === name)"); - this.curlyBlock(["if", "(isArray)"], () => { - this.lineSM("result[name] = subExts.map(e => (e as Record)[valueField])"); - }); - this.curlyBlock(["else if", "(subExts[0])"], () => { - this.lineSM("result[name] = (subExts[0] as Record)[valueField]"); - }); - }); - this.lineSM("return result"); - }, - ); - this.line(); - // extractSliceSimplified - remove match keys from slice (reverse of applySliceMatch) - this.curlyBlock( - [ - "export const", - "extractSliceSimplified", - "=", - ">(slice: T, matchKeys: string[]): Partial", - "=>", - ], - () => { - this.lineSM("const result = { ...slice } as Record"); - this.curlyBlock(["for (const", "key", "of", "matchKeys)"], () => { - this.lineSM("delete result[key]"); - }); - this.lineSM("return result as Partial"); - }, - ); - }); - } - - private generateProfileHelpersImport(options: { - needsGetOrCreateObjectAtPath: boolean; - needsSliceHelpers: boolean; - needsExtensionExtraction: boolean; - needsSliceExtraction: boolean; - }) { - const imports: string[] = []; - if (options.needsSliceHelpers) { - imports.push("applySliceMatch", "matchesSlice"); - } - if (options.needsGetOrCreateObjectAtPath) { - imports.push("getOrCreateObjectAtPath"); - } - if (options.needsExtensionExtraction) { - imports.push("extractComplexExtension"); - } - if (options.needsSliceExtraction) { - imports.push("extractSliceSimplified"); - } - if (imports.length > 0) { - this.lineSM(`import { ${imports.join(", ")} } from "../../profile-helpers"`); - } - } - - private collectTypesFromSlices(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); - } - } - } - } - - private collectTypesFromExtensions( - tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, - addType: (typeId: Identifier) => void, - ): boolean { - let needsExtensionType = false; - - for (const ext of flatProfile.extensions ?? []) { - if (ext.isComplex && ext.subExtensions) { - needsExtensionType = true; - for (const sub of ext.subExtensions) { - if (!sub.valueType) continue; - const resolvedType = tsIndex.resolveByUrl( - flatProfile.identifier.package, - sub.valueType.url as CanonicalUrl, - ); - addType(resolvedType?.identifier ?? sub.valueType); - } - } else if (ext.valueTypes && ext.valueTypes.length === 1) { - needsExtensionType = true; - if (ext.valueTypes[0]) { - const resolvedType = tsIndex.resolveByUrl( - flatProfile.identifier.package, - ext.valueTypes[0].url as CanonicalUrl, - ); - addType(resolvedType?.identifier ?? ext.valueTypes[0]); - } - } else { - needsExtensionType = true; - } - } - - return needsExtensionType; - } - - private collectTypesFromFieldOverrides( - tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, - addType: (typeId: Identifier) => void, - ) { - const referenceUrl = "http://hl7.org/fhir/StructureDefinition/Reference" as CanonicalUrl; - const referenceSchema = tsIndex.resolveByUrl(flatProfile.identifier.package, referenceUrl); - const specialization = tsIndex.findLastSpecialization(flatProfile); - - if (!isSpecializationTypeSchema(specialization)) return; - - for (const [fieldName, pField] of Object.entries(flatProfile.fields ?? {})) { - if (!isNotChoiceDeclarationField(pField)) continue; - const sField = specialization.fields?.[fieldName]; - if (!sField || isChoiceDeclarationField(sField)) continue; - - if (pField.reference && sField.reference && pField.reference.length < sField.reference.length) { - if (referenceSchema) addType(referenceSchema.identifier); - } else if (pField.required && !sField.required && pField.type) { - addType(pField.type); - } - } - } - - private generateProfileImports(tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) { - const usedTypes = new Map(); - - const getModulePath = (typeId: Identifier): string => { - if (isNestedIdentifier(typeId)) { - const path = canonicalToName(typeId.url, true); - if (path) return `../../${tsFhirPackageDir(typeId.package)}/${pascalCase(path)}`; - } - return `../../${tsModulePath(typeId)}`; - }; - - const addType = (typeId: Identifier) => { - if (typeId.kind === "primitive-type") return; - const tsName = tsResourceName(typeId); - if (!usedTypes.has(tsName)) { - usedTypes.set(tsName, { importPath: getModulePath(typeId), tsName }); - } - }; - - addType(flatProfile.base); - this.collectTypesFromSlices(flatProfile, addType); - const needsExtensionType = this.collectTypesFromExtensions(tsIndex, flatProfile, addType); - this.collectTypesFromFieldOverrides(tsIndex, flatProfile, addType); - - const factoryInfo = collectProfileFactoryInfo(flatProfile); - for (const param of factoryInfo.params) addType(param.typeId); - - if (needsExtensionType) { - const extensionUrl = "http://hl7.org/fhir/StructureDefinition/Extension" as CanonicalUrl; - const extensionSchema = tsIndex.resolveByUrl(flatProfile.identifier.package, extensionUrl); - if (extensionSchema) addType(extensionSchema.identifier); - } - - const sortedImports = Array.from(usedTypes.values()).sort((a, b) => a.tsName.localeCompare(b.tsName)); - for (const { importPath, tsName } of sortedImports) { - this.tsImportType(importPath, tsName); - } - if (sortedImports.length > 0) this.line(); - } - - generateProfileClass(tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema, schema?: TypeSchema) { - const tsBaseResourceName = tsTypeFromIdentifier(flatProfile.base); - const tsProfileName = tsResourceName(flatProfile.identifier); - const profileClassName = tsProfileClassName(flatProfile); - - // Known polymorphic field base names in FHIR (value[x], effective[x], etc.) - // These don't exist as direct properties on TypeScript types - const polymorphicBaseNames = new Set([ - "value", - "effective", - "onset", - "abatement", - "occurrence", - "timing", - "deceased", - "born", - "age", - "medication", - "performed", - "serviced", - "collected", - "item", - "subject", - "bounds", - "amount", - "content", - "product", - "rate", - "dose", - "asNeeded", - ]); - - const sliceDefs = Object.entries(flatProfile.fields ?? {}) - .filter(([_fieldName, field]) => isNotChoiceDeclarationField(field) && field.slicing?.slices) - .flatMap(([fieldName, field]) => { - if (!isNotChoiceDeclarationField(field) || !field.slicing?.slices || !field.type) return []; - const baseType = tsTypeFromIdentifier(field.type); - return Object.entries(field.slicing.slices) - .filter(([_sliceName, slice]) => { - const match = slice.match ?? {}; - return Object.keys(match).length > 0; - }) - .map(([sliceName, slice]) => { - const matchFields = Object.keys(slice.match ?? {}); - const required = slice.required ?? []; - // Filter out fields that are in match or polymorphic base names - const filteredRequired = required.filter( - (name) => !matchFields.includes(name) && !polymorphicBaseNames.has(name), - ); - return { - fieldName, - baseType, - sliceName, - match: slice.match ?? {}, - required, - excluded: slice.excluded ?? [], - array: Boolean(field.array), - // Input is optional when there are no required fields after filtering - inputOptional: filteredRequired.length === 0, - }; - }); - }); - - const extensions = flatProfile.extensions ?? []; - const complexExtensions = extensions.filter((ext) => ext.isComplex && ext.subExtensions); - - for (const ext of complexExtensions) { - const typeName = tsExtensionInputTypeName(tsProfileName, ext.name); - this.curlyBlock(["export", "type", typeName, "="], () => { - for (const sub of ext.subExtensions ?? []) { - const tsType = sub.valueType ? tsTypeFromIdentifier(sub.valueType) : "unknown"; - const isArray = sub.max === "*"; - const isRequired = sub.min !== undefined && sub.min > 0; - this.lineSM(`${sub.name}${isRequired ? "" : "?"}: ${tsType}${isArray ? "[]" : ""}`); - } - }); - this.line(); - } - - if (sliceDefs.length > 0) { - for (const sliceDef of sliceDefs) { - const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); - const matchFields = Object.keys(sliceDef.match); - const allExcluded = [...new Set([...sliceDef.excluded, ...matchFields])]; - 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( - (name) => !matchFields.includes(name) && !polymorphicBaseNames.has(name), - ); - const requiredNames = filteredRequired.map((name) => JSON.stringify(name)); - let typeExpr = sliceDef.baseType; - if (excludedNames.length > 0) { - typeExpr = `Omit<${typeExpr}, ${excludedNames.join(" | ")}>`; - } - if (requiredNames.length > 0) { - typeExpr = `${typeExpr} & Required>`; - } - this.lineSM(`export type ${typeName} = ${typeExpr}`); - } - this.line(); - } - - // Determine which helpers are actually needed - const needsSliceHelpers = sliceDefs.length > 0; - const extensionsWithNestedPath = extensions.filter((ext) => { - const targetPath = ext.path.split(".").filter((segment) => segment !== "extension"); - return targetPath.length > 0; - }); - const needsGetOrCreateObjectAtPath = extensionsWithNestedPath.length > 0; - const needsExtensionExtraction = complexExtensions.length > 0; - const needsSliceExtraction = sliceDefs.length > 0; - - if (needsSliceHelpers || needsGetOrCreateObjectAtPath || needsExtensionExtraction || needsSliceExtraction) { - this.generateProfileHelpersImport({ - needsGetOrCreateObjectAtPath, - needsSliceHelpers, - needsExtensionExtraction, - needsSliceExtraction, - }); - this.line(); - } - - // Check if we have an override interface (narrowed types) - const hasOverrideInterface = this.detectFieldOverrides(tsIndex, flatProfile).size > 0; - const factoryInfo = collectProfileFactoryInfo(flatProfile); - - const hasParams = factoryInfo.params.length > 0; - const createArgsTypeName = `${profileClassName}Params`; - const paramSignature = hasParams ? `args: ${createArgsTypeName}` : ""; - const allFields = [ - ...factoryInfo.autoFields.map((f) => ({ name: f.name, value: f.value })), - ...factoryInfo.params.map((p) => ({ name: p.name, value: `args.${p.name}` })), - ]; - - if (hasParams) { - this.curlyBlock(["export", "type", createArgsTypeName, "="], () => { - for (const p of factoryInfo.params) { - this.lineSM(`${p.name}: ${p.tsType}`); - } - }); - this.line(); - } - - if (schema) { - this.comment("CanonicalURL:", schema.identifier.url, `(pkg: ${packageMetaToFhir(packageMeta(schema))})`); - } - this.curlyBlock(["export", "class", profileClassName], () => { - this.line(`private resource: ${tsBaseResourceName}`); - this.line(); - this.curlyBlock(["constructor", `(resource: ${tsBaseResourceName})`], () => { - this.line("this.resource = resource"); - }); - this.line(); - this.curlyBlock(["static", "from", `(resource: ${tsBaseResourceName})`, `: ${profileClassName}`], () => { - this.line(`return new ${profileClassName}(resource)`); - }); - this.line(); - this.curlyBlock(["static", "createResource", `(${paramSignature})`, `: ${tsBaseResourceName}`], () => { - this.curlyBlock([`const resource: ${tsBaseResourceName} =`], () => { - for (const f of allFields) { - this.line(`${f.name}: ${f.value},`); - } - }, [` as unknown as ${tsBaseResourceName}`]); - this.line("return resource"); - }); - this.line(); - this.curlyBlock(["static", "create", `(${paramSignature})`, `: ${profileClassName}`], () => { - this.line( - `return ${profileClassName}.from(${profileClassName}.createResource(${hasParams ? "args" : ""}))`, - ); - }); - this.line(); - // toResource() returns base type (e.g., Patient) - this.curlyBlock(["toResource", "()", `: ${tsBaseResourceName}`], () => { - this.line("return this.resource"); - }); - this.line(); - // Getter and setter methods for required profile fields - for (const p of factoryInfo.params) { - const methodSuffix = uppercaseFirstLetter(p.name); - this.curlyBlock([`get${methodSuffix}`, "()", `: ${p.tsType} | undefined`], () => { - this.line(`return this.resource.${p.name} as ${p.tsType} | undefined`); - }); - this.line(); - this.curlyBlock([`set${methodSuffix}`, `(value: ${p.tsType})`, ": this"], () => { - this.line(`(this.resource as any).${p.name} = value`); - this.line("return this"); - }); - this.line(); - } - // toProfile() returns casted profile type if override interface exists - if (hasOverrideInterface) { - this.curlyBlock(["toProfile", "()", `: ${tsProfileName}`], () => { - this.line(`return this.resource as ${tsProfileName}`); - }); - this.line(); - } - - const extensionMethods = extensions - .filter((ext) => ext.url) - .map((ext) => ({ - ext, - baseName: tsExtensionMethodName(ext.name), - fallbackName: tsExtensionMethodFallback(ext.name, ext.path), - })); - const sliceMethodBases = sliceDefs.map((slice) => tsSliceMethodName(slice.sliceName)); - const methodCounts = new Map(); - for (const name of [...sliceMethodBases, ...extensionMethods.map((m) => m.baseName)]) { - methodCounts.set(name, (methodCounts.get(name) ?? 0) + 1); - } - const extensionMethodNames = new Map( - extensionMethods.map((entry) => [ - entry.ext, - (methodCounts.get(entry.baseName) ?? 0) > 1 ? entry.fallbackName : entry.baseName, - ]), - ); - const sliceMethodNames = new Map( - sliceDefs.map((slice) => { - const baseName = tsSliceMethodName(slice.sliceName); - const needsFallback = (methodCounts.get(baseName) ?? 0) > 1; - const fallback = tsSliceMethodFallback(slice.fieldName, slice.sliceName); - return [slice, needsFallback ? fallback : baseName]; - }), - ); - - this.generateExtensionSetterMethods(extensions, extensionMethodNames, tsProfileName); - - for (const sliceDef of sliceDefs) { - const methodName = - sliceMethodNames.get(sliceDef) ?? tsSliceMethodFallback(sliceDef.fieldName, sliceDef.sliceName); - const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); - const matchLiteral = JSON.stringify(sliceDef.match); - 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) - const paramSignature = sliceDef.inputOptional - ? `(input?: ${typeName}): this` - : `(input: ${typeName}): this`; - this.curlyBlock(["public", methodName, paramSignature], () => { - this.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"; - this.line(`const value = applySliceMatch(${inputExpr}, match) as unknown as ${sliceDef.baseType}`); - if (sliceDef.array) { - this.line(`const list = (${fieldAccess} ??= [])`); - this.line("const index = list.findIndex((item) => matchesSlice(item, match))"); - this.line("if (index === -1) {"); - this.indentBlock(() => { - this.line("list.push(value)"); - }); - this.line("} else {"); - this.indentBlock(() => { - this.line("list[index] = value"); - }); - this.line("}"); - } else { - this.line(`${fieldAccess} = value`); - } - this.line("return this"); - }); - this.line(); - } - - // Generate extension getters - two methods per extension: - // 1. get{Name}() - returns flat API (simplified) - // 2. get{Name}Extension() - returns raw FHIR Extension - const generatedGetMethods = new Set(); - - for (const ext of extensions) { - if (!ext.url) continue; - const baseName = uppercaseFirstLetter(safeCamelCase(ext.name)); - const getMethodName = `get${baseName}`; - const getExtensionMethodName = `get${baseName}Extension`; - if (generatedGetMethods.has(getMethodName)) continue; - generatedGetMethods.add(getMethodName); - const valueTypes = ext.valueTypes ?? []; - const targetPath = ext.path.split(".").filter((segment) => segment !== "extension"); - - // Helper to generate the extension lookup code - const generateExtLookup = () => { - if (targetPath.length === 0) { - this.line(`const ext = this.resource.extension?.find(e => e.url === "${ext.url}")`); - } else { - this.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, - ); - this.line( - `const ext = (target.extension as Extension[] | undefined)?.find(e => e.url === "${ext.url}")`, - ); - } - }; - - if (ext.isComplex && ext.subExtensions) { - const inputTypeName = tsExtensionInputTypeName(tsProfileName, ext.name); - // Flat API getter - this.curlyBlock(["public", getMethodName, `(): ${inputTypeName} | undefined`], () => { - generateExtLookup(); - this.line("if (!ext) return undefined"); - // Build extraction config - const configItems = (ext.subExtensions ?? []).map((sub) => { - const valueField = sub.valueType - ? `value${uppercaseFirstLetter(sub.valueType.name)}` - : "value"; - const isArray = sub.max === "*"; - return `{ name: "${sub.url}", valueField: "${valueField}", isArray: ${isArray} }`; - }); - this.line(`const config = [${configItems.join(", ")}]`); - this.line( - `return extractComplexExtension(ext as unknown as { extension?: Array<{ url?: string; [key: string]: unknown }> }, config) as ${inputTypeName}`, - ); - }); - this.line(); - // Raw Extension getter - this.curlyBlock(["public", getExtensionMethodName, "(): Extension | undefined"], () => { - generateExtLookup(); - this.line("return ext"); - }); - } else if (valueTypes.length === 1 && valueTypes[0]) { - const firstValueType = valueTypes[0]; - const valueType = tsTypeFromIdentifier(firstValueType); - const valueField = `value${uppercaseFirstLetter(firstValueType.name)}`; - // Flat API getter (cast needed: value field may not exist on Extension in this FHIR version) - this.curlyBlock(["public", getMethodName, `(): ${valueType} | undefined`], () => { - generateExtLookup(); - this.line( - `return (ext as Record | undefined)?.${valueField} as ${valueType} | undefined`, - ); - }); - this.line(); - // Raw Extension getter - this.curlyBlock(["public", getExtensionMethodName, "(): Extension | undefined"], () => { - generateExtLookup(); - this.line("return ext"); - }); - } else { - // Generic extension - only raw getter makes sense - this.curlyBlock(["public", getMethodName, "(): Extension | undefined"], () => { - if (targetPath.length === 0) { - this.line(`return this.resource.extension?.find(e => e.url === "${ext.url}")`); - } else { - this.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, - ); - this.line( - `return (target.extension as Extension[] | undefined)?.find(e => e.url === "${ext.url}")`, - ); - } - }); - } - this.line(); - } - - // Generate slice getters - two methods per slice: - // 1. get{SliceName}() - returns simplified (without discriminator fields) - // 2. get{SliceName}Raw() - returns full FHIR type with all fields - for (const sliceDef of sliceDefs) { - const baseName = uppercaseFirstLetter(safeCamelCase(sliceDef.sliceName)); - const getMethodName = `get${baseName}`; - const getRawMethodName = `get${baseName}Raw`; - if (generatedGetMethods.has(getMethodName)) continue; - generatedGetMethods.add(getMethodName); - const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); - const matchLiteral = JSON.stringify(sliceDef.match); - const matchKeys = JSON.stringify(Object.keys(sliceDef.match)); - const tsField = tsFieldName(sliceDef.fieldName); - const fieldAccess = tsGet("this.resource", tsField); - const baseType = sliceDef.baseType; - - // Helper to find the slice item - const generateSliceLookup = () => { - this.line(`const match = ${matchLiteral} as Record`); - if (sliceDef.array) { - this.line(`const list = ${fieldAccess}`); - this.line("if (!list) return undefined"); - this.line("const item = list.find((item) => matchesSlice(item, match))"); - } else { - this.line(`const item = ${fieldAccess}`); - this.line("if (!item || !matchesSlice(item, match)) return undefined"); - } - }; - - // Flat API getter (simplified) - this.curlyBlock(["public", getMethodName, `(): ${typeName} | undefined`], () => { - generateSliceLookup(); - if (sliceDef.array) { - this.line("if (!item) return undefined"); - } - this.line( - `return extractSliceSimplified(item as unknown as Record, ${matchKeys}) as ${typeName}`, - ); - }); - this.line(); - - // Raw getter (full FHIR type) - this.curlyBlock(["public", getRawMethodName, `(): ${baseType} | undefined`], () => { - generateSliceLookup(); - if (sliceDef.array) { - this.line("return item"); - } else { - this.line("return item"); - } - }); - this.line(); - } - }); - this.line(); - } - - private generateExtensionSetterMethods( - extensions: ProfileExtension[], - extensionMethodNames: Map, - tsProfileName: string, - ) { - for (const ext of extensions) { - if (!ext.url) continue; - const methodName = extensionMethodNames.get(ext) ?? tsExtensionMethodFallback(ext.name, ext.path); - const valueTypes = ext.valueTypes ?? []; - const targetPath = ext.path.split(".").filter((segment) => segment !== "extension"); - - if (ext.isComplex && ext.subExtensions) { - const inputTypeName = tsExtensionInputTypeName(tsProfileName, ext.name); - this.curlyBlock(["public", methodName, `(input: ${inputTypeName}): this`], () => { - this.line("const subExtensions: Extension[] = []"); - for (const sub of ext.subExtensions ?? []) { - const valueField = sub.valueType ? `value${uppercaseFirstLetter(sub.valueType.name)}` : "value"; - // When value type is unknown, cast to Extension to avoid TS error - const needsCast = !sub.valueType; - const pushSuffix = needsCast ? " as Extension" : ""; - if (sub.max === "*") { - this.curlyBlock(["if", `(input.${sub.name})`], () => { - this.curlyBlock(["for", `(const item of input.${sub.name})`], () => { - this.line( - `subExtensions.push({ url: "${sub.url}", ${valueField}: item }${pushSuffix})`, - ); - }); - }); - } else { - this.curlyBlock(["if", `(input.${sub.name} !== undefined)`], () => { - this.line( - `subExtensions.push({ url: "${sub.url}", ${valueField}: input.${sub.name} }${pushSuffix})`, - ); - }); - } - } - if (targetPath.length === 0) { - this.line("const list = (this.resource.extension ??= [])"); - this.line(`list.push({ url: "${ext.url}", extension: subExtensions })`); - } else { - this.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, - ); - this.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); - this.line( - `(target.extension as Extension[]).push({ url: "${ext.url}", extension: subExtensions })`, - ); - } - this.line("return this"); - }); - } else if (valueTypes.length === 1 && valueTypes[0]) { - const firstValueType = valueTypes[0]; - const valueType = tsTypeFromIdentifier(firstValueType); - const valueField = `value${uppercaseFirstLetter(firstValueType.name)}`; - this.curlyBlock(["public", methodName, `(value: ${valueType}): this`], () => { - // Cast needed: value field may not exist on Extension in this FHIR version - const extLiteral = `{ url: "${ext.url}", ${valueField}: value } as Extension`; - if (targetPath.length === 0) { - this.line("const list = (this.resource.extension ??= [])"); - this.line(`list.push(${extLiteral})`); - } else { - this.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( - targetPath, - )})`, - ); - this.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); - this.line(`(target.extension as Extension[]).push(${extLiteral})`); - } - this.line("return this"); - }); - } else { - this.curlyBlock(["public", methodName, `(value: Omit): this`], () => { - if (targetPath.length === 0) { - this.line("const list = (this.resource.extension ??= [])"); - this.line(`list.push({ url: "${ext.url}", ...value })`); - } else { - this.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( - targetPath, - )})`, - ); - this.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); - this.line(`(target.extension as Extension[]).push({ url: "${ext.url}", ...value })`); - } - this.line("return this"); - }); - } - this.line(); - } - } - - /** - * Detects fields where the profile changes cardinality or narrows Reference types - * compared to the base resource type. - */ - private detectFieldOverrides( - tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, - ): Map { - const overrides = new Map(); - const specialization = tsIndex.findLastSpecialization(flatProfile); - if (!isSpecializationTypeSchema(specialization)) return overrides; - - for (const [fieldName, pField] of Object.entries(flatProfile.fields ?? {})) { - if (!isNotChoiceDeclarationField(pField)) continue; - const sField = specialization.fields?.[fieldName]; - if (!sField || isChoiceDeclarationField(sField)) continue; - - // Check for Reference narrowing - if (pField.reference && sField.reference && pField.reference.length < sField.reference.length) { - const references = pField.reference - .map((ref) => { - const resRef = tsIndex.findLastSpecializationByIdentifier(ref); - if (resRef.name !== ref.name) { - return `"${resRef.name}"`; - } - return `"${ref.name}"`; - }) - .join(" | "); - overrides.set(fieldName, { - profileType: `Reference<${references}>`, - required: pField.required ?? false, - array: pField.array ?? false, - }); - } - // Check for cardinality change (optional -> required) - else if (pField.required && !sField.required) { - const tsType = this.tsTypeForProfileField(tsIndex, flatProfile, fieldName, pField); - overrides.set(fieldName, { - profileType: tsType, - required: true, - array: pField.array ?? false, - }); - } - } - return overrides; - } - - /** - * Generates an override interface for profiles that narrow cardinality or Reference types. - * Example: export interface USCorePatient extends Patient { subject: Reference<"Patient"> } - */ - generateProfileOverrideInterface(tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) { - const overrides = this.detectFieldOverrides(tsIndex, flatProfile); - if (overrides.size === 0) return; - - const tsProfileName = tsResourceName(flatProfile.identifier); - const tsBaseResourceName = tsResourceName(flatProfile.base); - - this.curlyBlock(["export", "interface", tsProfileName, "extends", tsBaseResourceName], () => { - for (const [fieldName, override] of overrides) { - const tsField = tsFieldName(fieldName); - const optionalSymbol = override.required ? "" : "?"; - const arraySymbol = override.array ? "[]" : ""; - this.lineSM(`${tsField}${optionalSymbol}: ${override.profileType}${arraySymbol}`); - } - }); - this.line(); - } - generateResourceModule(tsIndex: TypeSchemaIndex, schema: TypeSchema) { if (isProfileTypeSchema(schema)) { this.cd("profiles", () => { this.cat(`${tsProfileModuleFileName(tsIndex, schema)}`, () => { this.generateDisclaimer(); const flatProfile = tsIndex.flatProfile(schema); - this.generateProfileImports(tsIndex, flatProfile); - this.generateProfileOverrideInterface(tsIndex, flatProfile); - this.generateProfileClass(tsIndex, flatProfile, schema); + generateProfileImports(this, tsIndex, flatProfile); + generateProfileOverrideInterface(this, tsIndex, flatProfile); + generateProfileClass(this, tsIndex, flatProfile, schema); }); }); } else if (["complex-type", "resource", "logical"].includes(schema.identifier.kind)) { @@ -1633,7 +299,7 @@ export class TypeScript extends Writer { this.cd("/", () => { if (hasProfiles) { - this.generateProfileHelpersModule(); + generateProfileHelpersModule(this); } for (const [packageName, packageSchemas] of Object.entries(grouped)) { @@ -1642,7 +308,7 @@ export class TypeScript extends Writer { for (const schema of packageSchemas) { this.generateResourceModule(tsIndex, schema); } - this.generateProfileIndexFile(tsIndex, packageSchemas.filter(isProfileTypeSchema)); + generateProfileIndexFile(this, tsIndex, packageSchemas.filter(isProfileTypeSchema)); this.generateFhirPackageIndexFile(packageSchemas); }); } From dfd738e4e76d9e02ce8227d8b6039f389d653529 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 26 Feb 2026 20:02:09 +0100 Subject: [PATCH 3/8] Extract naming functions to typescript/name.ts Move all name-generation functions (module names, file names, field names, profile class names, method names, etc.) from utils.ts and profile.ts into a dedicated name.ts file. --- src/api/writer-generator/typescript/name.ts | 124 ++++++++++++++++++ .../writer-generator/typescript/profile.ts | 66 ++-------- src/api/writer-generator/typescript/utils.ts | 71 +--------- src/api/writer-generator/typescript/writer.ts | 20 +-- 4 files changed, 145 insertions(+), 136 deletions(-) create mode 100644 src/api/writer-generator/typescript/name.ts diff --git a/src/api/writer-generator/typescript/name.ts b/src/api/writer-generator/typescript/name.ts new file mode 100644 index 00000000..a9f17610 --- /dev/null +++ b/src/api/writer-generator/typescript/name.ts @@ -0,0 +1,124 @@ +import { + camelCase, + kebabCase, + uppercaseFirstLetter, + uppercaseFirstLetterOfEach, +} from "@root/api/writer-generator/utils"; +import { + type CanonicalUrl, + extractNameFromCanonical, + type Identifier, + type ProfileTypeSchema, +} from "@root/typeschema/types"; +import type { TypeSchemaIndex } from "@root/typeschema/utils"; + +// biome-ignore format: too long +export const tsKeywords = new Set([ "class", "function", "return", "if", "for", "while", "const", "let", "var", "import", "export", "interface" ]); + +export const normalizeTsName = (n: string): string => { + if (tsKeywords.has(n)) n = `${n}_`; + return n.replace(/\[x\]/g, "_x_").replace(/[- :.]/g, "_"); +}; + +export const safeCamelCase = (name: string): string => { + if (!name) return ""; + // Remove [x] suffix and normalize special characters before camelCase + const normalized = name.replace(/\[x\]/g, "").replace(/:/g, "_"); + return camelCase(normalized); +}; + +export const tsFhirPackageDir = (name: string): string => { + return kebabCase(name); +}; + +export const tsModuleName = (id: Identifier): string => { + // NOTE: Why not pascal case? + // In hl7-fhir-uv-xver-r5-r4 we have: + // - http://hl7.org/fhir/5.0/StructureDefinition/extension-Subscription.topic (subscription_topic) + // - http://hl7.org/fhir/5.0/StructureDefinition/extension-SubscriptionTopic (SubscriptionTopic) + // And they should not clash the names. + return uppercaseFirstLetter(normalizeTsName(id.name)); +}; + +export const tsModuleFileName = (id: Identifier): string => { + return `${tsModuleName(id)}.ts`; +}; + +export const tsModulePath = (id: Identifier): string => { + return `${tsFhirPackageDir(id.package)}/${tsModuleName(id)}`; +}; + +export const canonicalToName = (canonical: string | undefined, dropFragment = true) => { + if (!canonical) return undefined; + const localName = extractNameFromCanonical(canonical as CanonicalUrl, dropFragment); + if (!localName) return undefined; + return normalizeTsName(localName); +}; + +export const tsResourceName = (id: Identifier): string => { + if (id.kind === "nested") { + const url = id.url; + // Extract name from URL without normalizing dots (needed for fragment splitting) + const localName = extractNameFromCanonical(url as CanonicalUrl, false); + if (!localName) return ""; + const [resourceName, fragment] = localName.split("#"); + const name = uppercaseFirstLetterOfEach((fragment ?? "").split(".")).join(""); + return normalizeTsName([resourceName, name].join("")); + } + return normalizeTsName(id.name); +}; + +export const tsFieldName = (n: string): string => { + if (tsKeywords.has(n)) return `"${n}"`; + if (n.includes(" ") || n.includes("-")) return `"${n}"`; + return n; +}; + +export const tsProfileModuleName = (tsIndex: TypeSchemaIndex, schema: ProfileTypeSchema): string => { + const resourceSchema = tsIndex.findLastSpecialization(schema); + const resourceName = uppercaseFirstLetter(normalizeTsName(resourceSchema.identifier.name)); + return `${resourceName}_${normalizeTsName(schema.identifier.name)}`; +}; + +export const tsProfileModuleFileName = (tsIndex: TypeSchemaIndex, schema: ProfileTypeSchema): string => { + return `${tsProfileModuleName(tsIndex, schema)}.ts`; +}; + +export const tsProfileClassName = (schema: ProfileTypeSchema): string => { + return `${normalizeTsName(schema.identifier.name)}Profile`; +}; + +export const tsSliceInputTypeName = (profileName: string, fieldName: string, sliceName: string): string => { + return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(fieldName))}_${uppercaseFirstLetter(normalizeTsName(sliceName))}SliceInput`; +}; + +export const tsExtensionInputTypeName = (profileName: string, extensionName: string): string => { + return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(extensionName))}Input`; +}; + +export const tsSliceMethodName = (sliceName: string): string => { + const normalized = safeCamelCase(sliceName); + return `set${uppercaseFirstLetter(normalized || "Slice")}`; +}; + +export const tsExtensionMethodName = (name: string): string => { + const normalized = safeCamelCase(name); + return `set${uppercaseFirstLetter(normalized || "Extension")}`; +}; + +export const tsExtensionMethodFallback = (name: string, path?: string): string => { + const rawPath = + path + ?.split(".") + .filter((p) => p && p !== "extension") + .join("_") ?? ""; + const pathPart = rawPath ? uppercaseFirstLetter(safeCamelCase(rawPath)) : ""; + const normalized = safeCamelCase(name); + return `setExtension${pathPart}${uppercaseFirstLetter(normalized || "Extension")}`; +}; + +export const tsSliceMethodFallback = (fieldName: string, sliceName: string): string => { + const fieldPart = uppercaseFirstLetter(safeCamelCase(fieldName) || "Field"); + const slicePart = uppercaseFirstLetter(safeCamelCase(sliceName) || "Slice"); + return `setSlice${fieldPart}${slicePart}`; +}; diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index a17a04f8..dae19eba 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -18,34 +18,23 @@ import { import type { TypeSchemaIndex } from "@root/typeschema/utils"; import { canonicalToName, - normalizeTsName, - resolveFieldTsType, - resolvePrimitiveType, safeCamelCase, - tsEnumType, + tsExtensionInputTypeName, + tsExtensionMethodFallback, + tsExtensionMethodName, tsFhirPackageDir, tsFieldName, - tsGet, tsModulePath, + tsProfileClassName, + tsProfileModuleName, tsResourceName, - tsTypeFromIdentifier, -} from "./utils"; + tsSliceInputTypeName, + tsSliceMethodFallback, + tsSliceMethodName, +} from "./name"; +import { resolveFieldTsType, resolvePrimitiveType, tsEnumType, tsGet, tsTypeFromIdentifier } from "./utils"; import type { TypeScript } from "./writer"; -export const tsProfileModuleName = (tsIndex: TypeSchemaIndex, schema: ProfileTypeSchema): string => { - const resourceSchema = tsIndex.findLastSpecialization(schema); - const resourceName = uppercaseFirstLetter(normalizeTsName(resourceSchema.identifier.name)); - return `${resourceName}_${normalizeTsName(schema.identifier.name)}`; -}; - -export const tsProfileModuleFileName = (tsIndex: TypeSchemaIndex, schema: ProfileTypeSchema): string => { - return `${tsProfileModuleName(tsIndex, schema)}.ts`; -}; - -export const tsProfileClassName = (schema: ProfileTypeSchema): string => { - return `${normalizeTsName(schema.identifier.name)}Profile`; -}; - export type ProfileFactoryInfo = { autoFields: { name: string; value: string }[]; params: { name: string; tsType: string; typeId: Identifier }[]; @@ -94,41 +83,6 @@ export const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): Profi return { autoFields, params }; }; -export const tsSliceInputTypeName = (profileName: string, fieldName: string, sliceName: string): string => { - return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(fieldName))}_${uppercaseFirstLetter(normalizeTsName(sliceName))}SliceInput`; -}; - -export const tsExtensionInputTypeName = (profileName: string, extensionName: string): string => { - return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(extensionName))}Input`; -}; - -export const tsSliceMethodName = (sliceName: string): string => { - const normalized = safeCamelCase(sliceName); - return `set${uppercaseFirstLetter(normalized || "Slice")}`; -}; - -export const tsExtensionMethodName = (name: string): string => { - const normalized = safeCamelCase(name); - return `set${uppercaseFirstLetter(normalized || "Extension")}`; -}; - -export const tsExtensionMethodFallback = (name: string, path?: string): string => { - const rawPath = - path - ?.split(".") - .filter((p) => p && p !== "extension") - .join("_") ?? ""; - const pathPart = rawPath ? uppercaseFirstLetter(safeCamelCase(rawPath)) : ""; - const normalized = safeCamelCase(name); - return `setExtension${pathPart}${uppercaseFirstLetter(normalized || "Extension")}`; -}; - -export const tsSliceMethodFallback = (fieldName: string, sliceName: string): string => { - const fieldPart = uppercaseFirstLetter(safeCamelCase(fieldName) || "Field"); - const slicePart = uppercaseFirstLetter(safeCamelCase(sliceName) || "Slice"); - return `setSlice${fieldPart}${slicePart}`; -}; - export const generateProfileIndexFile = ( writer: TypeScript, tsIndex: TypeSchemaIndex, diff --git a/src/api/writer-generator/typescript/utils.ts b/src/api/writer-generator/typescript/utils.ts index 07b0529b..f1c88ebc 100644 --- a/src/api/writer-generator/typescript/utils.ts +++ b/src/api/writer-generator/typescript/utils.ts @@ -1,19 +1,12 @@ import { - camelCase, - kebabCase, - uppercaseFirstLetter, - uppercaseFirstLetterOfEach, -} from "@root/api/writer-generator/utils"; -import { - type CanonicalUrl, type ChoiceFieldInstance, type EnumDefinition, - extractNameFromCanonical, type Identifier, isNestedIdentifier, isPrimitiveIdentifier, type RegularField, } from "@root/typeschema/types"; +import { tsResourceName } from "./name"; export const primitiveType2tsType: Record = { boolean: "boolean", @@ -48,61 +41,6 @@ export const resolvePrimitiveType = (name: string) => { return tsType; }; -export const tsFhirPackageDir = (name: string): string => { - return kebabCase(name); -}; - -export const tsModuleName = (id: Identifier): string => { - // NOTE: Why not pascal case? - // In hl7-fhir-uv-xver-r5-r4 we have: - // - http://hl7.org/fhir/5.0/StructureDefinition/extension-Subscription.topic (subscription_topic) - // - http://hl7.org/fhir/5.0/StructureDefinition/extension-SubscriptionTopic (SubscriptionTopic) - // And they should not clash the names. - return uppercaseFirstLetter(normalizeTsName(id.name)); -}; - -export const tsModuleFileName = (id: Identifier): string => { - return `${tsModuleName(id)}.ts`; -}; - -export const tsModulePath = (id: Identifier): string => { - return `${tsFhirPackageDir(id.package)}/${tsModuleName(id)}`; -}; - -export const canonicalToName = (canonical: string | undefined, dropFragment = true) => { - if (!canonical) return undefined; - const localName = extractNameFromCanonical(canonical as CanonicalUrl, dropFragment); - if (!localName) return undefined; - return normalizeTsName(localName); -}; - -export const tsResourceName = (id: Identifier): string => { - if (id.kind === "nested") { - const url = id.url; - // Extract name from URL without normalizing dots (needed for fragment splitting) - const localName = extractNameFromCanonical(url as CanonicalUrl, false); - if (!localName) return ""; - const [resourceName, fragment] = localName.split("#"); - const name = uppercaseFirstLetterOfEach((fragment ?? "").split(".")).join(""); - return normalizeTsName([resourceName, name].join("")); - } - return normalizeTsName(id.name); -}; - -// biome-ignore format: too long -export const tsKeywords = new Set([ "class", "function", "return", "if", "for", "while", "const", "let", "var", "import", "export", "interface" ]); - -export const tsFieldName = (n: string): string => { - if (tsKeywords.has(n)) return `"${n}"`; - if (n.includes(" ") || n.includes("-")) return `"${n}"`; - return n; -}; - -export const normalizeTsName = (n: string): string => { - if (tsKeywords.has(n)) n = `${n}_`; - return n.replace(/\[x\]/g, "_x_").replace(/[- :.]/g, "_"); -}; - export const tsGet = (object: string, tsFieldName: string) => { if (tsFieldName.startsWith('"')) return `${object}[${tsFieldName}]`; return `${object}.${tsFieldName}`; @@ -150,10 +88,3 @@ export const tsTypeFromIdentifier = (id: Identifier): string => { if (primitiveType !== undefined) return primitiveType; return id.name; }; - -export const safeCamelCase = (name: string): string => { - if (!name) return ""; - // Remove [x] suffix and normalize special characters before camelCase - const normalized = name.replace(/\[x\]/g, "").replace(/:/g, "_"); - return camelCase(normalized); -}; diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts index c3c15068..aeed0aa9 100644 --- a/src/api/writer-generator/typescript/writer.ts +++ b/src/api/writer-generator/typescript/writer.ts @@ -17,24 +17,24 @@ import { type TypeSchema, } from "@root/typeschema/types"; import { groupByPackages, type TypeSchemaIndex } from "@root/typeschema/utils"; -import { - generateProfileClass, - generateProfileHelpersModule, - generateProfileImports, - generateProfileIndexFile, - generateProfileOverrideInterface, - tsProfileModuleFileName, -} from "./profile"; import { canonicalToName, - resolveFieldTsType, tsFhirPackageDir, tsFieldName, tsModuleFileName, tsModuleName, tsModulePath, + tsProfileModuleFileName, tsResourceName, -} from "./utils"; +} from "./name"; +import { + generateProfileClass, + generateProfileHelpersModule, + generateProfileImports, + generateProfileIndexFile, + generateProfileOverrideInterface, +} from "./profile"; +import { resolveFieldTsType } from "./utils"; export type TypeScriptOptions = { /** openResourceTypeSet -- for resource families (Resource, DomainResource) use open set for resourceType field. From 8eb0138a4bc2d53a8f640cdcb2834e780746a29f Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 26 Feb 2026 20:17:33 +0100 Subject: [PATCH 4/8] Reduce exports and remove dead code - Make internal-only symbols non-exported in name.ts, utils.ts, profile.ts - Remove unused generateProfileType, generateAttachProfile, generateExtractProfile functions (dead code) --- src/api/writer-generator/typescript/name.ts | 4 +- .../writer-generator/typescript/profile.ts | 140 +----------------- src/api/writer-generator/typescript/utils.ts | 4 +- 3 files changed, 7 insertions(+), 141 deletions(-) diff --git a/src/api/writer-generator/typescript/name.ts b/src/api/writer-generator/typescript/name.ts index a9f17610..2a501524 100644 --- a/src/api/writer-generator/typescript/name.ts +++ b/src/api/writer-generator/typescript/name.ts @@ -13,9 +13,9 @@ import { import type { TypeSchemaIndex } from "@root/typeschema/utils"; // biome-ignore format: too long -export const tsKeywords = new Set([ "class", "function", "return", "if", "for", "while", "const", "let", "var", "import", "export", "interface" ]); +const tsKeywords = new Set([ "class", "function", "return", "if", "for", "while", "const", "let", "var", "import", "export", "interface" ]); -export const normalizeTsName = (n: string): string => { +const normalizeTsName = (n: string): string => { if (tsKeywords.has(n)) n = `${n}_`; return n.replace(/\[x\]/g, "_x_").replace(/[- :.]/g, "_"); }; diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index dae19eba..b8742748 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -35,12 +35,12 @@ import { import { resolveFieldTsType, resolvePrimitiveType, tsEnumType, tsGet, tsTypeFromIdentifier } from "./utils"; import type { TypeScript } from "./writer"; -export type ProfileFactoryInfo = { +type ProfileFactoryInfo = { autoFields: { name: string; value: string }[]; params: { name: string; tsType: string; typeId: Identifier }[]; }; -export const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFactoryInfo => { +const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFactoryInfo => { const autoFields: ProfileFactoryInfo["autoFields"] = []; const params: ProfileFactoryInfo["params"] = []; const fields = flatProfile.fields ?? {}; @@ -179,140 +179,6 @@ const tsTypeForProfileField = ( return tsType; }; -export const generateProfileType = (writer: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { - writer.debugComment("flatProfile", flatProfile); - const tsName = tsResourceName(flatProfile.identifier); - writer.debugComment("identifier", flatProfile.identifier); - writer.debugComment("base", flatProfile.base); - writer.curlyBlock(["export", "interface", tsName], () => { - writer.lineSM(`__profileUrl: "${flatProfile.identifier.url}"`); - writer.line(); - - for (const [fieldName, field] of Object.entries(flatProfile.fields ?? {})) { - if (isChoiceDeclarationField(field)) continue; - writer.debugComment(fieldName, field); - - const tsName = tsFieldName(fieldName); - const tsType = tsTypeForProfileField(writer, tsIndex, flatProfile, fieldName, field); - writer.lineSM(`${tsName}${!field.required ? "?" : ""}: ${tsType}${field.array ? "[]" : ""}`); - } - }); - - writer.line(); -}; - -export const generateAttachProfile = (writer: TypeScript, flatProfile: ProfileTypeSchema) => { - const tsBaseResourceName = tsResourceName(flatProfile.base); - const tsProfileName = tsResourceName(flatProfile.identifier); - const profileFields = Object.entries(flatProfile.fields || {}) - .filter(([_fieldName, field]) => { - return field && isNotChoiceDeclarationField(field) && field.type !== undefined; - }) - .map(([fieldName]) => tsFieldName(fieldName)); - - writer.curlyBlock( - [ - `export const attach_${tsProfileName}_to_${tsBaseResourceName} =`, - `(resource: ${tsBaseResourceName}, profile: ${tsProfileName}): ${tsBaseResourceName}`, - "=>", - ], - () => { - writer.curlyBlock(["return"], () => { - writer.line("...resource,"); - // FIXME: don't rewrite all profiles - writer.curlyBlock(["meta:"], () => { - writer.line(`profile: ['${flatProfile.identifier.url}']`); - }, [","]); - profileFields.forEach((fieldName) => { - writer.line(`${fieldName}: ${tsGet("profile", fieldName)},`); - }); - }); - }, - ); - writer.line(); -}; - -export const generateExtractProfile = ( - writer: TypeScript, - tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, -) => { - const tsBaseResourceName = tsResourceName(flatProfile.base); - const tsProfileName = tsResourceName(flatProfile.identifier); - - const profileFields = Object.entries(flatProfile.fields || {}) - .filter(([_fieldName, field]) => { - return isNotChoiceDeclarationField(field) && field.type !== undefined; - }) - .map(([fieldName]) => fieldName); - - const specialization = tsIndex.findLastSpecialization(flatProfile); - if (!isSpecializationTypeSchema(specialization)) - throw new Error(`Specialization not found for ${flatProfile.identifier.url}`); - - const shouldCast: Record = {}; - writer.curlyBlock( - [ - `export const extract_${tsProfileName}_from_${tsBaseResourceName} =`, - `(resource: ${tsBaseResourceName}): ${tsProfileName}`, - "=>", - ], - () => { - profileFields.forEach((fieldName) => { - const tsField = tsFieldName(fieldName); - const pField = flatProfile.fields?.[fieldName]; - const rField = specialization.fields?.[fieldName]; - if (!isNotChoiceDeclarationField(pField) || !isNotChoiceDeclarationField(rField)) return; - - if (pField.required && !rField.required) { - writer.curlyBlock([`if (${tsGet("resource", tsField)} === undefined)`], () => - writer.lineSM(`throw new Error("'${tsField}' is required for ${flatProfile.identifier.url}")`), - ); - } - - const pRefs = pField?.reference?.map((ref) => ref.name); - const rRefs = rField?.reference?.map((ref) => ref.name); - if (pRefs && rRefs && pRefs.length !== rRefs.length) { - const predName = `reference_is_valid_${tsField}`; - writer.curlyBlock(["const", predName, "=", "(ref?: Reference)", "=>"], () => { - writer.line("return !ref"); - writer.indentBlock(() => { - rRefs.forEach((ref) => { - writer.line(`|| ref.reference?.startsWith('${ref}/')`); - }); - writer.line(";"); - }); - }); - let cond: string = !pField?.required ? `!${tsGet("resource", tsField)} || ` : ""; - if (pField.array) { - cond += `${tsGet("resource", tsField)}.every( (ref) => ${predName}(ref) )`; - } else { - cond += `!${predName}(${tsGet("resource", tsField)})`; - } - writer.curlyBlock(["if (", cond, ")"], () => { - writer.lineSM( - `throw new Error("'${fieldName}' has different references in profile and specialization")`, - ); - }); - writer.line(); - shouldCast[fieldName] = true; - } - }); - writer.curlyBlock(["return"], () => { - writer.line(`__profileUrl: '${flatProfile.identifier.url}',`); - profileFields.forEach((fieldName) => { - const tsField = tsFieldName(fieldName); - if (shouldCast[fieldName]) { - writer.line(`${tsField}:`, `${tsGet("resource", tsField)} as ${tsProfileName}['${tsField}'],`); - } else { - writer.line(`${tsField}:`, `${tsGet("resource", tsField)},`); - } - }); - }); - }, - ); -}; - export const generateProfileHelpersModule = (writer: TypeScript) => { writer.cat("profile-helpers.ts", () => { writer.generateDisclaimer(); @@ -1122,7 +988,7 @@ const generateExtensionSetterMethods = ( } }; -export const detectFieldOverrides = ( +const detectFieldOverrides = ( writer: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema, diff --git a/src/api/writer-generator/typescript/utils.ts b/src/api/writer-generator/typescript/utils.ts index f1c88ebc..aa9e1382 100644 --- a/src/api/writer-generator/typescript/utils.ts +++ b/src/api/writer-generator/typescript/utils.ts @@ -8,7 +8,7 @@ import { } from "@root/typeschema/types"; import { tsResourceName } from "./name"; -export const primitiveType2tsType: Record = { +const primitiveType2tsType: Record = { boolean: "boolean", instant: "string", time: "string", @@ -51,7 +51,7 @@ export const tsEnumType = (enumDef: EnumDefinition) => { return enumDef.isOpen ? `(${values} | string)` : `(${values})`; }; -export const rewriteFieldTypeDefs: Record string>> = { +const rewriteFieldTypeDefs: Record string>> = { Coding: { code: () => "T" }, // biome-ignore lint: that is exactly string what we want Reference: { reference: () => "`${T}/${string}`" }, From 7af49c1e2a788c79ead79cd205db0f87d42353e1 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 26 Feb 2026 20:20:03 +0100 Subject: [PATCH 5/8] Rename fallback naming functions to tsQualified* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tsExtensionMethodFallback → tsQualifiedExtensionMethodName - tsSliceMethodFallback → tsQualifiedSliceMethodName --- src/api/writer-generator/typescript/name.ts | 4 ++-- src/api/writer-generator/typescript/profile.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/api/writer-generator/typescript/name.ts b/src/api/writer-generator/typescript/name.ts index 2a501524..d77f99ac 100644 --- a/src/api/writer-generator/typescript/name.ts +++ b/src/api/writer-generator/typescript/name.ts @@ -106,7 +106,7 @@ export const tsExtensionMethodName = (name: string): string => { return `set${uppercaseFirstLetter(normalized || "Extension")}`; }; -export const tsExtensionMethodFallback = (name: string, path?: string): string => { +export const tsQualifiedExtensionMethodName = (name: string, path?: string): string => { const rawPath = path ?.split(".") @@ -117,7 +117,7 @@ export const tsExtensionMethodFallback = (name: string, path?: string): string = return `setExtension${pathPart}${uppercaseFirstLetter(normalized || "Extension")}`; }; -export const tsSliceMethodFallback = (fieldName: string, sliceName: string): string => { +export const tsQualifiedSliceMethodName = (fieldName: string, sliceName: string): string => { const fieldPart = uppercaseFirstLetter(safeCamelCase(fieldName) || "Field"); const slicePart = uppercaseFirstLetter(safeCamelCase(sliceName) || "Slice"); return `setSlice${fieldPart}${slicePart}`; diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index b8742748..429cb314 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -20,16 +20,16 @@ import { canonicalToName, safeCamelCase, tsExtensionInputTypeName, - tsExtensionMethodFallback, tsExtensionMethodName, tsFhirPackageDir, tsFieldName, tsModulePath, tsProfileClassName, tsProfileModuleName, + tsQualifiedExtensionMethodName, + tsQualifiedSliceMethodName, tsResourceName, tsSliceInputTypeName, - tsSliceMethodFallback, tsSliceMethodName, } from "./name"; import { resolveFieldTsType, resolvePrimitiveType, tsEnumType, tsGet, tsTypeFromIdentifier } from "./utils"; @@ -690,7 +690,7 @@ export const generateProfileClass = ( .map((ext) => ({ ext, baseName: tsExtensionMethodName(ext.name), - fallbackName: tsExtensionMethodFallback(ext.name, ext.path), + fallbackName: tsQualifiedExtensionMethodName(ext.name, ext.path), })); const sliceMethodBases = sliceDefs.map((slice) => tsSliceMethodName(slice.sliceName)); const methodCounts = new Map(); @@ -707,7 +707,7 @@ export const generateProfileClass = ( sliceDefs.map((slice) => { const baseName = tsSliceMethodName(slice.sliceName); const needsFallback = (methodCounts.get(baseName) ?? 0) > 1; - const fallback = tsSliceMethodFallback(slice.fieldName, slice.sliceName); + const fallback = tsQualifiedSliceMethodName(slice.fieldName, slice.sliceName); return [slice, needsFallback ? fallback : baseName]; }), ); @@ -716,7 +716,7 @@ export const generateProfileClass = ( for (const sliceDef of sliceDefs) { const methodName = - sliceMethodNames.get(sliceDef) ?? tsSliceMethodFallback(sliceDef.fieldName, sliceDef.sliceName); + sliceMethodNames.get(sliceDef) ?? tsQualifiedSliceMethodName(sliceDef.fieldName, sliceDef.sliceName); const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); const matchLiteral = JSON.stringify(sliceDef.match); const tsField = tsFieldName(sliceDef.fieldName); @@ -903,7 +903,7 @@ const generateExtensionSetterMethods = ( ) => { for (const ext of extensions) { if (!ext.url) continue; - const methodName = extensionMethodNames.get(ext) ?? tsExtensionMethodFallback(ext.name, ext.path); + const methodName = extensionMethodNames.get(ext) ?? tsQualifiedExtensionMethodName(ext.name, ext.path); const valueTypes = ext.valueTypes ?? []; const targetPath = ext.path.split(".").filter((segment) => segment !== "extension"); From d84ecb29675680fbebe00ea78c723effd3483bdb Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Thu, 26 Feb 2026 20:31:55 +0100 Subject: [PATCH 6/8] Rename naming functions for consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - canonicalToName → tsNameFromCanonical - safeCamelCase → tsCamelCase - tsFhirPackageDir → tsPackageDir --- src/api/writer-generator/typescript/name.ts | 20 +++++++++---------- .../writer-generator/typescript/profile.ts | 14 ++++++------- src/api/writer-generator/typescript/writer.ts | 12 +++++------ 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/api/writer-generator/typescript/name.ts b/src/api/writer-generator/typescript/name.ts index d77f99ac..e27feeb6 100644 --- a/src/api/writer-generator/typescript/name.ts +++ b/src/api/writer-generator/typescript/name.ts @@ -20,14 +20,14 @@ const normalizeTsName = (n: string): string => { return n.replace(/\[x\]/g, "_x_").replace(/[- :.]/g, "_"); }; -export const safeCamelCase = (name: string): string => { +export const tsCamelCase = (name: string): string => { if (!name) return ""; // Remove [x] suffix and normalize special characters before camelCase const normalized = name.replace(/\[x\]/g, "").replace(/:/g, "_"); return camelCase(normalized); }; -export const tsFhirPackageDir = (name: string): string => { +export const tsPackageDir = (name: string): string => { return kebabCase(name); }; @@ -45,10 +45,10 @@ export const tsModuleFileName = (id: Identifier): string => { }; export const tsModulePath = (id: Identifier): string => { - return `${tsFhirPackageDir(id.package)}/${tsModuleName(id)}`; + return `${tsPackageDir(id.package)}/${tsModuleName(id)}`; }; -export const canonicalToName = (canonical: string | undefined, dropFragment = true) => { +export const tsNameFromCanonical = (canonical: string | undefined, dropFragment = true) => { if (!canonical) return undefined; const localName = extractNameFromCanonical(canonical as CanonicalUrl, dropFragment); if (!localName) return undefined; @@ -97,12 +97,12 @@ export const tsExtensionInputTypeName = (profileName: string, extensionName: str }; export const tsSliceMethodName = (sliceName: string): string => { - const normalized = safeCamelCase(sliceName); + const normalized = tsCamelCase(sliceName); return `set${uppercaseFirstLetter(normalized || "Slice")}`; }; export const tsExtensionMethodName = (name: string): string => { - const normalized = safeCamelCase(name); + const normalized = tsCamelCase(name); return `set${uppercaseFirstLetter(normalized || "Extension")}`; }; @@ -112,13 +112,13 @@ export const tsQualifiedExtensionMethodName = (name: string, path?: string): str ?.split(".") .filter((p) => p && p !== "extension") .join("_") ?? ""; - const pathPart = rawPath ? uppercaseFirstLetter(safeCamelCase(rawPath)) : ""; - const normalized = safeCamelCase(name); + const pathPart = rawPath ? uppercaseFirstLetter(tsCamelCase(rawPath)) : ""; + const normalized = tsCamelCase(name); return `setExtension${pathPart}${uppercaseFirstLetter(normalized || "Extension")}`; }; export const tsQualifiedSliceMethodName = (fieldName: string, sliceName: string): string => { - const fieldPart = uppercaseFirstLetter(safeCamelCase(fieldName) || "Field"); - const slicePart = uppercaseFirstLetter(safeCamelCase(sliceName) || "Slice"); + const fieldPart = uppercaseFirstLetter(tsCamelCase(fieldName) || "Field"); + const slicePart = uppercaseFirstLetter(tsCamelCase(sliceName) || "Slice"); return `setSlice${fieldPart}${slicePart}`; }; diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 429cb314..5c9e2e30 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -17,13 +17,13 @@ import { } from "@root/typeschema/types"; import type { TypeSchemaIndex } from "@root/typeschema/utils"; import { - canonicalToName, - safeCamelCase, + tsCamelCase, tsExtensionInputTypeName, tsExtensionMethodName, - tsFhirPackageDir, tsFieldName, tsModulePath, + tsNameFromCanonical, + tsPackageDir, tsProfileClassName, tsProfileModuleName, tsQualifiedExtensionMethodName, @@ -447,8 +447,8 @@ export const generateProfileImports = ( const getModulePath = (typeId: Identifier): string => { if (isNestedIdentifier(typeId)) { - const path = canonicalToName(typeId.url, true); - if (path) return `../../${tsFhirPackageDir(typeId.package)}/${pascalCase(path)}`; + const path = tsNameFromCanonical(typeId.url, true); + if (path) return `../../${tsPackageDir(typeId.package)}/${pascalCase(path)}`; } return `../../${tsModulePath(typeId)}`; }; @@ -759,7 +759,7 @@ export const generateProfileClass = ( for (const ext of extensions) { if (!ext.url) continue; - const baseName = uppercaseFirstLetter(safeCamelCase(ext.name)); + const baseName = uppercaseFirstLetter(tsCamelCase(ext.name)); const getMethodName = `get${baseName}`; const getExtensionMethodName = `get${baseName}Extension`; if (generatedGetMethods.has(getMethodName)) continue; @@ -843,7 +843,7 @@ export const generateProfileClass = ( // 1. get{SliceName}() - returns simplified (without discriminator fields) // 2. get{SliceName}Raw() - returns full FHIR type with all fields for (const sliceDef of sliceDefs) { - const baseName = uppercaseFirstLetter(safeCamelCase(sliceDef.sliceName)); + const baseName = uppercaseFirstLetter(tsCamelCase(sliceDef.sliceName)); const getMethodName = `get${baseName}`; const getRawMethodName = `get${baseName}Raw`; if (generatedGetMethods.has(getMethodName)) continue; diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts index aeed0aa9..dc335fe9 100644 --- a/src/api/writer-generator/typescript/writer.ts +++ b/src/api/writer-generator/typescript/writer.ts @@ -18,12 +18,12 @@ import { } from "@root/typeschema/types"; import { groupByPackages, type TypeSchemaIndex } from "@root/typeschema/utils"; import { - canonicalToName, - tsFhirPackageDir, tsFieldName, tsModuleFileName, tsModuleName, tsModulePath, + tsNameFromCanonical, + tsPackageDir, tsProfileModuleFileName, tsResourceName, } from "./name"; @@ -114,7 +114,7 @@ export class TypeScript extends Writer { }); } else if (isNestedIdentifier(dep)) { const ndep = { ...dep }; - ndep.name = canonicalToName(dep.url) as Name; + ndep.name = tsNameFromCanonical(dep.url) as Name; imports.push({ tsPackage: `${importPrefix}${tsModulePath(ndep)}`, name: tsResourceName(dep), @@ -179,7 +179,7 @@ export class TypeScript extends Writer { } let extendsClause: string | undefined; - if (schema.base) extendsClause = `extends ${canonicalToName(schema.base.url)}`; + if (schema.base) extendsClause = `extends ${tsNameFromCanonical(schema.base.url)}`; this.debugComment(schema.identifier); if (!schema.fields && !extendsClause && !isResourceTypeSchema(schema)) { @@ -303,8 +303,8 @@ export class TypeScript extends Writer { } for (const [packageName, packageSchemas] of Object.entries(grouped)) { - const tsPackageDir = tsFhirPackageDir(packageName); - this.cd(tsPackageDir, () => { + const packageDir = tsPackageDir(packageName); + this.cd(packageDir, () => { for (const schema of packageSchemas) { this.generateResourceModule(tsIndex, schema); } From 46388d7435b12f09bff56f62adf4f02bc9140dc3 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 27 Feb 2026 09:15:01 +0100 Subject: [PATCH 7/8] Rename writer parameter to w in profile.ts --- .../writer-generator/typescript/profile.ts | 442 +++++++++--------- 1 file changed, 212 insertions(+), 230 deletions(-) diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 5c9e2e30..e46ac9f9 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -84,17 +84,17 @@ const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFacto }; export const generateProfileIndexFile = ( - writer: TypeScript, + w: TypeScript, tsIndex: TypeSchemaIndex, initialProfiles: ProfileTypeSchema[], ) => { if (initialProfiles.length === 0) return; - writer.cd("profiles", () => { - writer.cat("index.ts", () => { + w.cd("profiles", () => { + w.cat("index.ts", () => { const profiles: [ProfileTypeSchema, string, string | undefined][] = initialProfiles.map((profile) => { const className = tsProfileClassName(profile); const resourceName = tsResourceName(profile.identifier); - const overrides = detectFieldOverrides(writer, tsIndex, profile); + const overrides = detectFieldOverrides(w, tsIndex, profile); let typeExport; if (overrides.size > 0) typeExport = resourceName; return [profile, className, typeExport]; @@ -113,14 +113,14 @@ export const generateProfileIndexFile = ( } const allExports = [...classExports.values(), ...typeExports.values()].sort(); for (const exp of allExports) { - writer.lineSM(exp); + w.lineSM(exp); } }); }); }; const tsTypeForProfileField = ( - _writer: TypeScript, + _w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema, fieldName: string, @@ -179,17 +179,17 @@ const tsTypeForProfileField = ( return tsType; }; -export const generateProfileHelpersModule = (writer: TypeScript) => { - writer.cat("profile-helpers.ts", () => { - writer.generateDisclaimer(); - writer.curlyBlock( +export const generateProfileHelpersModule = (w: TypeScript) => { + w.cat("profile-helpers.ts", () => { + w.generateDisclaimer(); + w.curlyBlock( ["export const", "isRecord", "=", "(value: unknown): value is Record", "=>"], () => { - writer.lineSM('return value !== null && typeof value === "object" && !Array.isArray(value)'); + w.lineSM('return value !== null && typeof value === "object" && !Array.isArray(value)'); }, ); - writer.line(); - writer.curlyBlock( + w.line(); + w.curlyBlock( [ "export const", "getOrCreateObjectAtPath", @@ -198,27 +198,27 @@ export const generateProfileHelpersModule = (writer: TypeScript) => { "=>", ], () => { - writer.lineSM("let current: Record = root"); - writer.curlyBlock(["for (const", "segment", "of", "path)"], () => { - writer.curlyBlock(["if", "(Array.isArray(current[segment]))"], () => { - writer.lineSM("const list = current[segment] as unknown[]"); - writer.curlyBlock(["if", "(list.length === 0)"], () => { - writer.lineSM("list.push({})"); + 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({})"); }); - writer.lineSM("current = list[0] as Record"); + w.lineSM("current = list[0] as Record"); }); - writer.curlyBlock(["else"], () => { - writer.curlyBlock(["if", "(!isRecord(current[segment]))"], () => { - writer.lineSM("current[segment] = {}"); + w.curlyBlock(["else"], () => { + w.curlyBlock(["if", "(!isRecord(current[segment]))"], () => { + w.lineSM("current[segment] = {}"); }); - writer.lineSM("current = current[segment] as Record"); + w.lineSM("current = current[segment] as Record"); }); }); - writer.lineSM("return current"); + w.lineSM("return current"); }, ); - writer.line(); - writer.curlyBlock( + w.line(); + w.curlyBlock( [ "export const", "mergeMatch", @@ -227,29 +227,29 @@ export const generateProfileHelpersModule = (writer: TypeScript) => { "=>", ], () => { - writer.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { - writer.curlyBlock( + w.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { + w.curlyBlock( ["if", '(key === "__proto__" || key === "constructor" || key === "prototype")'], () => { - writer.lineSM("continue"); + w.lineSM("continue"); }, ); - writer.curlyBlock(["if", "(isRecord(matchValue))"], () => { - writer.curlyBlock(["if", "(isRecord(target[key]))"], () => { - writer.lineSM("mergeMatch(target[key] as Record, matchValue)"); + w.curlyBlock(["if", "(isRecord(matchValue))"], () => { + w.curlyBlock(["if", "(isRecord(target[key]))"], () => { + w.lineSM("mergeMatch(target[key] as Record, matchValue)"); }); - writer.curlyBlock(["else"], () => { - writer.lineSM("target[key] = { ...matchValue }"); + w.curlyBlock(["else"], () => { + w.lineSM("target[key] = { ...matchValue }"); }); }); - writer.curlyBlock(["else"], () => { - writer.lineSM("target[key] = matchValue"); + w.curlyBlock(["else"], () => { + w.lineSM("target[key] = matchValue"); }); }); }, ); - writer.line(); - writer.curlyBlock( + w.line(); + w.curlyBlock( [ "export const", "applySliceMatch", @@ -258,46 +258,38 @@ export const generateProfileHelpersModule = (writer: TypeScript) => { "=>", ], () => { - writer.lineSM("const result = { ...input } as Record"); - writer.lineSM("mergeMatch(result, match)"); - writer.lineSM("return result as T"); + w.lineSM("const result = { ...input } as Record"); + w.lineSM("mergeMatch(result, match)"); + w.lineSM("return result as T"); }, ); - writer.line(); - writer.curlyBlock( - ["export const", "matchesValue", "=", "(value: unknown, match: unknown): boolean", "=>"], - () => { - writer.curlyBlock(["if", "(Array.isArray(match))"], () => { - writer.curlyBlock(["if", "(!Array.isArray(value))"], () => writer.lineSM("return false")); - writer.lineSM( - "return match.every((matchItem) => value.some((item) => matchesValue(item, matchItem)))", - ); - }); - writer.curlyBlock(["if", "(isRecord(match))"], () => { - writer.curlyBlock(["if", "(!isRecord(value))"], () => writer.lineSM("return false")); - writer.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { - writer.curlyBlock( - ["if", "(!matchesValue((value as Record)[key], matchValue))"], - () => { - writer.lineSM("return false"); - }, - ); + 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"); }); - writer.lineSM("return true"); }); - writer.lineSM("return value === match"); - }, - ); - writer.line(); - writer.curlyBlock( + w.lineSM("return true"); + }); + w.lineSM("return value === match"); + }); + w.line(); + w.curlyBlock( ["export const", "matchesSlice", "=", "(value: unknown, match: Record): boolean", "=>"], () => { - writer.lineSM("return matchesValue(value, match)"); + w.lineSM("return matchesValue(value, match)"); }, ); - writer.line(); + w.line(); // extractComplexExtension - extract sub-extension values from complex extension - writer.curlyBlock( + w.curlyBlock( [ "export const", "extractComplexExtension", @@ -306,23 +298,23 @@ export const generateProfileHelpersModule = (writer: TypeScript) => { "=>", ], () => { - writer.lineSM("if (!extension?.extension) return undefined"); - writer.lineSM("const result: Record = {}"); - writer.curlyBlock(["for (const", "{ name, valueField, isArray }", "of", "config)"], () => { - writer.lineSM("const subExts = extension.extension.filter(e => e.url === name)"); - writer.curlyBlock(["if", "(isArray)"], () => { - writer.lineSM("result[name] = subExts.map(e => (e as Record)[valueField])"); + 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])"); }); - writer.curlyBlock(["else if", "(subExts[0])"], () => { - writer.lineSM("result[name] = (subExts[0] as Record)[valueField]"); + w.curlyBlock(["else if", "(subExts[0])"], () => { + w.lineSM("result[name] = (subExts[0] as Record)[valueField]"); }); }); - writer.lineSM("return result"); + w.lineSM("return result"); }, ); - writer.line(); + w.line(); // extractSliceSimplified - remove match keys from slice (reverse of applySliceMatch) - writer.curlyBlock( + w.curlyBlock( [ "export const", "extractSliceSimplified", @@ -331,18 +323,18 @@ export const generateProfileHelpersModule = (writer: TypeScript) => { "=>", ], () => { - writer.lineSM("const result = { ...slice } as Record"); - writer.curlyBlock(["for (const", "key", "of", "matchKeys)"], () => { - writer.lineSM("delete result[key]"); + w.lineSM("const result = { ...slice } as Record"); + w.curlyBlock(["for (const", "key", "of", "matchKeys)"], () => { + w.lineSM("delete result[key]"); }); - writer.lineSM("return result as Partial"); + w.lineSM("return result as Partial"); }, ); }); }; const generateProfileHelpersImport = ( - writer: TypeScript, + w: TypeScript, options: { needsGetOrCreateObjectAtPath: boolean; needsSliceHelpers: boolean; @@ -364,7 +356,7 @@ const generateProfileHelpersImport = ( imports.push("extractSliceSimplified"); } if (imports.length > 0) { - writer.lineSM(`import { ${imports.join(", ")} } from "../../profile-helpers"`); + w.lineSM(`import { ${imports.join(", ")} } from "../../profile-helpers"`); } }; @@ -438,11 +430,7 @@ const collectTypesFromFieldOverrides = ( } }; -export const generateProfileImports = ( - writer: TypeScript, - tsIndex: TypeSchemaIndex, - flatProfile: ProfileTypeSchema, -) => { +export const generateProfileImports = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { const usedTypes = new Map(); const getModulePath = (typeId: Identifier): string => { @@ -477,13 +465,13 @@ export const generateProfileImports = ( const sortedImports = Array.from(usedTypes.values()).sort((a, b) => a.tsName.localeCompare(b.tsName)); for (const { importPath, tsName } of sortedImports) { - writer.tsImportType(importPath, tsName); + w.tsImportType(importPath, tsName); } - if (sortedImports.length > 0) writer.line(); + if (sortedImports.length > 0) w.line(); }; export const generateProfileClass = ( - writer: TypeScript, + w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema, schema?: TypeSchema, @@ -555,15 +543,15 @@ export const generateProfileClass = ( for (const ext of complexExtensions) { const typeName = tsExtensionInputTypeName(tsProfileName, ext.name); - writer.curlyBlock(["export", "type", typeName, "="], () => { + w.curlyBlock(["export", "type", typeName, "="], () => { for (const sub of ext.subExtensions ?? []) { const tsType = sub.valueType ? tsTypeFromIdentifier(sub.valueType) : "unknown"; const isArray = sub.max === "*"; const isRequired = sub.min !== undefined && sub.min > 0; - writer.lineSM(`${sub.name}${isRequired ? "" : "?"}: ${tsType}${isArray ? "[]" : ""}`); + w.lineSM(`${sub.name}${isRequired ? "" : "?"}: ${tsType}${isArray ? "[]" : ""}`); } }); - writer.line(); + w.line(); } if (sliceDefs.length > 0) { @@ -584,9 +572,9 @@ export const generateProfileClass = ( if (requiredNames.length > 0) { typeExpr = `${typeExpr} & Required>`; } - writer.lineSM(`export type ${typeName} = ${typeExpr}`); + w.lineSM(`export type ${typeName} = ${typeExpr}`); } - writer.line(); + w.line(); } // Determine which helpers are actually needed @@ -600,17 +588,17 @@ export const generateProfileClass = ( const needsSliceExtraction = sliceDefs.length > 0; if (needsSliceHelpers || needsGetOrCreateObjectAtPath || needsExtensionExtraction || needsSliceExtraction) { - generateProfileHelpersImport(writer, { + generateProfileHelpersImport(w, { needsGetOrCreateObjectAtPath, needsSliceHelpers, needsExtensionExtraction, needsSliceExtraction, }); - writer.line(); + w.line(); } // Check if we have an override interface (narrowed types) - const hasOverrideInterface = detectFieldOverrides(writer, tsIndex, flatProfile).size > 0; + const hasOverrideInterface = detectFieldOverrides(w, tsIndex, flatProfile).size > 0; const factoryInfo = collectProfileFactoryInfo(flatProfile); const hasParams = factoryInfo.params.length > 0; @@ -622,67 +610,65 @@ export const generateProfileClass = ( ]; if (hasParams) { - writer.curlyBlock(["export", "type", createArgsTypeName, "="], () => { + w.curlyBlock(["export", "type", createArgsTypeName, "="], () => { for (const p of factoryInfo.params) { - writer.lineSM(`${p.name}: ${p.tsType}`); + w.lineSM(`${p.name}: ${p.tsType}`); } }); - writer.line(); + w.line(); } if (schema) { - writer.comment("CanonicalURL:", schema.identifier.url, `(pkg: ${packageMetaToFhir(packageMeta(schema))})`); + w.comment("CanonicalURL:", schema.identifier.url, `(pkg: ${packageMetaToFhir(packageMeta(schema))})`); } - writer.curlyBlock(["export", "class", profileClassName], () => { - writer.line(`private resource: ${tsBaseResourceName}`); - writer.line(); - writer.curlyBlock(["constructor", `(resource: ${tsBaseResourceName})`], () => { - writer.line("this.resource = resource"); + w.curlyBlock(["export", "class", profileClassName], () => { + w.line(`private resource: ${tsBaseResourceName}`); + w.line(); + w.curlyBlock(["constructor", `(resource: ${tsBaseResourceName})`], () => { + w.line("this.resource = resource"); }); - writer.line(); - writer.curlyBlock(["static", "from", `(resource: ${tsBaseResourceName})`, `: ${profileClassName}`], () => { - writer.line(`return new ${profileClassName}(resource)`); + w.line(); + w.curlyBlock(["static", "from", `(resource: ${tsBaseResourceName})`, `: ${profileClassName}`], () => { + w.line(`return new ${profileClassName}(resource)`); }); - writer.line(); - writer.curlyBlock(["static", "createResource", `(${paramSignature})`, `: ${tsBaseResourceName}`], () => { - writer.curlyBlock([`const resource: ${tsBaseResourceName} =`], () => { + w.line(); + w.curlyBlock(["static", "createResource", `(${paramSignature})`, `: ${tsBaseResourceName}`], () => { + w.curlyBlock([`const resource: ${tsBaseResourceName} =`], () => { for (const f of allFields) { - writer.line(`${f.name}: ${f.value},`); + w.line(`${f.name}: ${f.value},`); } }, [` as unknown as ${tsBaseResourceName}`]); - writer.line("return resource"); + w.line("return resource"); }); - writer.line(); - writer.curlyBlock(["static", "create", `(${paramSignature})`, `: ${profileClassName}`], () => { - writer.line( - `return ${profileClassName}.from(${profileClassName}.createResource(${hasParams ? "args" : ""}))`, - ); + w.line(); + w.curlyBlock(["static", "create", `(${paramSignature})`, `: ${profileClassName}`], () => { + w.line(`return ${profileClassName}.from(${profileClassName}.createResource(${hasParams ? "args" : ""}))`); }); - writer.line(); + w.line(); // toResource() returns base type (e.g., Patient) - writer.curlyBlock(["toResource", "()", `: ${tsBaseResourceName}`], () => { - writer.line("return this.resource"); + w.curlyBlock(["toResource", "()", `: ${tsBaseResourceName}`], () => { + w.line("return this.resource"); }); - writer.line(); + w.line(); // Getter and setter methods for required profile fields for (const p of factoryInfo.params) { const methodSuffix = uppercaseFirstLetter(p.name); - writer.curlyBlock([`get${methodSuffix}`, "()", `: ${p.tsType} | undefined`], () => { - writer.line(`return this.resource.${p.name} as ${p.tsType} | undefined`); + w.curlyBlock([`get${methodSuffix}`, "()", `: ${p.tsType} | undefined`], () => { + w.line(`return this.resource.${p.name} as ${p.tsType} | undefined`); }); - writer.line(); - writer.curlyBlock([`set${methodSuffix}`, `(value: ${p.tsType})`, ": this"], () => { - writer.line(`(this.resource as any).${p.name} = value`); - writer.line("return this"); + w.line(); + w.curlyBlock([`set${methodSuffix}`, `(value: ${p.tsType})`, ": this"], () => { + w.line(`(this.resource as any).${p.name} = value`); + w.line("return this"); }); - writer.line(); + w.line(); } // toProfile() returns casted profile type if override interface exists if (hasOverrideInterface) { - writer.curlyBlock(["toProfile", "()", `: ${tsProfileName}`], () => { - writer.line(`return this.resource as ${tsProfileName}`); + w.curlyBlock(["toProfile", "()", `: ${tsProfileName}`], () => { + w.line(`return this.resource as ${tsProfileName}`); }); - writer.line(); + w.line(); } const extensionMethods = extensions @@ -712,7 +698,7 @@ export const generateProfileClass = ( }), ); - generateExtensionSetterMethods(writer, extensions, extensionMethodNames, tsProfileName); + generateExtensionSetterMethods(w, extensions, extensionMethodNames, tsProfileName); for (const sliceDef of sliceDefs) { const methodName = @@ -725,31 +711,31 @@ export const generateProfileClass = ( const paramSignature = sliceDef.inputOptional ? `(input?: ${typeName}): this` : `(input: ${typeName}): this`; - writer.curlyBlock(["public", methodName, paramSignature], () => { - writer.line(`const match = ${matchLiteral} as Record`); + 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"; - writer.line(`const value = applySliceMatch(${inputExpr}, match) as unknown as ${sliceDef.baseType}`); + w.line(`const value = applySliceMatch(${inputExpr}, match) as unknown as ${sliceDef.baseType}`); if (sliceDef.array) { - writer.line(`const list = (${fieldAccess} ??= [])`); - writer.line("const index = list.findIndex((item) => matchesSlice(item, match))"); - writer.line("if (index === -1) {"); - writer.indentBlock(() => { - writer.line("list.push(value)"); + 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)"); }); - writer.line("} else {"); - writer.indentBlock(() => { - writer.line("list[index] = value"); + w.line("} else {"); + w.indentBlock(() => { + w.line("list[index] = value"); }); - writer.line("}"); + w.line("}"); } else { - writer.line(`${fieldAccess} = value`); + w.line(`${fieldAccess} = value`); } - writer.line("return this"); + w.line("return this"); }); - writer.line(); + w.line(); } // Generate extension getters - two methods per extension: @@ -770,12 +756,12 @@ export const generateProfileClass = ( // Helper to generate the extension lookup code const generateExtLookup = () => { if (targetPath.length === 0) { - writer.line(`const ext = this.resource.extension?.find(e => e.url === "${ext.url}")`); + w.line(`const ext = this.resource.extension?.find(e => e.url === "${ext.url}")`); } else { - writer.line( + w.line( `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, ); - writer.line( + w.line( `const ext = (target.extension as Extension[] | undefined)?.find(e => e.url === "${ext.url}")`, ); } @@ -784,59 +770,59 @@ export const generateProfileClass = ( if (ext.isComplex && ext.subExtensions) { const inputTypeName = tsExtensionInputTypeName(tsProfileName, ext.name); // Flat API getter - writer.curlyBlock(["public", getMethodName, `(): ${inputTypeName} | undefined`], () => { + w.curlyBlock(["public", getMethodName, `(): ${inputTypeName} | undefined`], () => { generateExtLookup(); - writer.line("if (!ext) return undefined"); + w.line("if (!ext) return undefined"); // Build extraction config const configItems = (ext.subExtensions ?? []).map((sub) => { const valueField = sub.valueType ? `value${uppercaseFirstLetter(sub.valueType.name)}` : "value"; const isArray = sub.max === "*"; return `{ name: "${sub.url}", valueField: "${valueField}", isArray: ${isArray} }`; }); - writer.line(`const config = [${configItems.join(", ")}]`); - writer.line( + w.line(`const config = [${configItems.join(", ")}]`); + w.line( `return extractComplexExtension(ext as unknown as { extension?: Array<{ url?: string; [key: string]: unknown }> }, config) as ${inputTypeName}`, ); }); - writer.line(); + w.line(); // Raw Extension getter - writer.curlyBlock(["public", getExtensionMethodName, "(): Extension | undefined"], () => { + w.curlyBlock(["public", getExtensionMethodName, "(): Extension | undefined"], () => { generateExtLookup(); - writer.line("return ext"); + w.line("return ext"); }); } else if (valueTypes.length === 1 && valueTypes[0]) { const firstValueType = valueTypes[0]; const valueType = tsTypeFromIdentifier(firstValueType); const valueField = `value${uppercaseFirstLetter(firstValueType.name)}`; // Flat API getter (cast needed: value field may not exist on Extension in this FHIR version) - writer.curlyBlock(["public", getMethodName, `(): ${valueType} | undefined`], () => { + w.curlyBlock(["public", getMethodName, `(): ${valueType} | undefined`], () => { generateExtLookup(); - writer.line( + w.line( `return (ext as Record | undefined)?.${valueField} as ${valueType} | undefined`, ); }); - writer.line(); + w.line(); // Raw Extension getter - writer.curlyBlock(["public", getExtensionMethodName, "(): Extension | undefined"], () => { + w.curlyBlock(["public", getExtensionMethodName, "(): Extension | undefined"], () => { generateExtLookup(); - writer.line("return ext"); + w.line("return ext"); }); } else { // Generic extension - only raw getter makes sense - writer.curlyBlock(["public", getMethodName, "(): Extension | undefined"], () => { + w.curlyBlock(["public", getMethodName, "(): Extension | undefined"], () => { if (targetPath.length === 0) { - writer.line(`return this.resource.extension?.find(e => e.url === "${ext.url}")`); + w.line(`return this.resource.extension?.find(e => e.url === "${ext.url}")`); } else { - writer.line( + w.line( `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, ); - writer.line( + w.line( `return (target.extension as Extension[] | undefined)?.find(e => e.url === "${ext.url}")`, ); } }); } - writer.line(); + w.line(); } // Generate slice getters - two methods per slice: @@ -857,46 +843,46 @@ export const generateProfileClass = ( // Helper to find the slice item const generateSliceLookup = () => { - writer.line(`const match = ${matchLiteral} as Record`); + w.line(`const match = ${matchLiteral} as Record`); if (sliceDef.array) { - writer.line(`const list = ${fieldAccess}`); - writer.line("if (!list) return undefined"); - writer.line("const item = list.find((item) => matchesSlice(item, match))"); + w.line(`const list = ${fieldAccess}`); + w.line("if (!list) return undefined"); + w.line("const item = list.find((item) => matchesSlice(item, match))"); } else { - writer.line(`const item = ${fieldAccess}`); - writer.line("if (!item || !matchesSlice(item, match)) return undefined"); + w.line(`const item = ${fieldAccess}`); + w.line("if (!item || !matchesSlice(item, match)) return undefined"); } }; // Flat API getter (simplified) - writer.curlyBlock(["public", getMethodName, `(): ${typeName} | undefined`], () => { + w.curlyBlock(["public", getMethodName, `(): ${typeName} | undefined`], () => { generateSliceLookup(); if (sliceDef.array) { - writer.line("if (!item) return undefined"); + w.line("if (!item) return undefined"); } - writer.line( + w.line( `return extractSliceSimplified(item as unknown as Record, ${matchKeys}) as ${typeName}`, ); }); - writer.line(); + w.line(); // Raw getter (full FHIR type) - writer.curlyBlock(["public", getRawMethodName, `(): ${baseType} | undefined`], () => { + w.curlyBlock(["public", getRawMethodName, `(): ${baseType} | undefined`], () => { generateSliceLookup(); if (sliceDef.array) { - writer.line("return item"); + w.line("return item"); } else { - writer.line("return item"); + w.line("return item"); } }); - writer.line(); + w.line(); } }); - writer.line(); + w.line(); }; const generateExtensionSetterMethods = ( - writer: TypeScript, + w: TypeScript, extensions: ProfileExtension[], extensionMethodNames: Map, tsProfileName: string, @@ -909,87 +895,83 @@ const generateExtensionSetterMethods = ( if (ext.isComplex && ext.subExtensions) { const inputTypeName = tsExtensionInputTypeName(tsProfileName, ext.name); - writer.curlyBlock(["public", methodName, `(input: ${inputTypeName}): this`], () => { - writer.line("const subExtensions: Extension[] = []"); + w.curlyBlock(["public", methodName, `(input: ${inputTypeName}): this`], () => { + w.line("const subExtensions: Extension[] = []"); for (const sub of ext.subExtensions ?? []) { const valueField = sub.valueType ? `value${uppercaseFirstLetter(sub.valueType.name)}` : "value"; // When value type is unknown, cast to Extension to avoid TS error const needsCast = !sub.valueType; const pushSuffix = needsCast ? " as Extension" : ""; if (sub.max === "*") { - writer.curlyBlock(["if", `(input.${sub.name})`], () => { - writer.curlyBlock(["for", `(const item of input.${sub.name})`], () => { - writer.line( - `subExtensions.push({ url: "${sub.url}", ${valueField}: item }${pushSuffix})`, - ); + w.curlyBlock(["if", `(input.${sub.name})`], () => { + w.curlyBlock(["for", `(const item of input.${sub.name})`], () => { + w.line(`subExtensions.push({ url: "${sub.url}", ${valueField}: item }${pushSuffix})`); }); }); } else { - writer.curlyBlock(["if", `(input.${sub.name} !== undefined)`], () => { - writer.line( + w.curlyBlock(["if", `(input.${sub.name} !== undefined)`], () => { + w.line( `subExtensions.push({ url: "${sub.url}", ${valueField}: input.${sub.name} }${pushSuffix})`, ); }); } } if (targetPath.length === 0) { - writer.line("const list = (this.resource.extension ??= [])"); - writer.line(`list.push({ url: "${ext.url}", extension: subExtensions })`); + w.line("const list = (this.resource.extension ??= [])"); + w.line(`list.push({ url: "${ext.url}", extension: subExtensions })`); } else { - writer.line( + w.line( `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, ); - writer.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); - writer.line( - `(target.extension as Extension[]).push({ url: "${ext.url}", extension: subExtensions })`, - ); + w.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); + w.line(`(target.extension as Extension[]).push({ url: "${ext.url}", extension: subExtensions })`); } - writer.line("return this"); + w.line("return this"); }); } else if (valueTypes.length === 1 && valueTypes[0]) { const firstValueType = valueTypes[0]; const valueType = tsTypeFromIdentifier(firstValueType); const valueField = `value${uppercaseFirstLetter(firstValueType.name)}`; - writer.curlyBlock(["public", methodName, `(value: ${valueType}): this`], () => { + w.curlyBlock(["public", methodName, `(value: ${valueType}): this`], () => { // Cast needed: value field may not exist on Extension in this FHIR version const extLiteral = `{ url: "${ext.url}", ${valueField}: value } as Extension`; if (targetPath.length === 0) { - writer.line("const list = (this.resource.extension ??= [])"); - writer.line(`list.push(${extLiteral})`); + w.line("const list = (this.resource.extension ??= [])"); + w.line(`list.push(${extLiteral})`); } else { - writer.line( + w.line( `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( targetPath, )})`, ); - writer.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); - writer.line(`(target.extension as Extension[]).push(${extLiteral})`); + w.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); + w.line(`(target.extension as Extension[]).push(${extLiteral})`); } - writer.line("return this"); + w.line("return this"); }); } else { - writer.curlyBlock(["public", methodName, `(value: Omit): this`], () => { + w.curlyBlock(["public", methodName, `(value: Omit): this`], () => { if (targetPath.length === 0) { - writer.line("const list = (this.resource.extension ??= [])"); - writer.line(`list.push({ url: "${ext.url}", ...value })`); + w.line("const list = (this.resource.extension ??= [])"); + w.line(`list.push({ url: "${ext.url}", ...value })`); } else { - writer.line( + w.line( `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( targetPath, )})`, ); - writer.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); - writer.line(`(target.extension as Extension[]).push({ url: "${ext.url}", ...value })`); + w.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); + w.line(`(target.extension as Extension[]).push({ url: "${ext.url}", ...value })`); } - writer.line("return this"); + w.line("return this"); }); } - writer.line(); + w.line(); } }; const detectFieldOverrides = ( - writer: TypeScript, + w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema, ): Map => { @@ -1021,7 +1003,7 @@ const detectFieldOverrides = ( } // Check for cardinality change (optional -> required) else if (pField.required && !sField.required) { - const tsType = tsTypeForProfileField(writer, tsIndex, flatProfile, fieldName, pField); + const tsType = tsTypeForProfileField(w, tsIndex, flatProfile, fieldName, pField); overrides.set(fieldName, { profileType: tsType, required: true, @@ -1033,23 +1015,23 @@ const detectFieldOverrides = ( }; export const generateProfileOverrideInterface = ( - writer: TypeScript, + w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema, ) => { - const overrides = detectFieldOverrides(writer, tsIndex, flatProfile); + const overrides = detectFieldOverrides(w, tsIndex, flatProfile); if (overrides.size === 0) return; const tsProfileName = tsResourceName(flatProfile.identifier); const tsBaseResourceName = tsResourceName(flatProfile.base); - writer.curlyBlock(["export", "interface", tsProfileName, "extends", tsBaseResourceName], () => { + w.curlyBlock(["export", "interface", tsProfileName, "extends", tsBaseResourceName], () => { for (const [fieldName, override] of overrides) { const tsField = tsFieldName(fieldName); const optionalSymbol = override.required ? "" : "?"; const arraySymbol = override.array ? "[]" : ""; - writer.lineSM(`${tsField}${optionalSymbol}: ${override.profileType}${arraySymbol}`); + w.lineSM(`${tsField}${optionalSymbol}: ${override.profileType}${arraySymbol}`); } }); - writer.line(); + w.line(); }; From aae78323afccb26b313e0d327ed6b838e44d51a1 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Fri, 27 Feb 2026 09:44:01 +0100 Subject: [PATCH 8/8] Fix high severity minimatch vulnerability (GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74) --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 1a5fe5df..49bb254c 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, }, "overrides": { - "minimatch": ">=10.2.1", + "minimatch": ">=10.2.3", "rollup": ">=4.59.0", }, "packages": { @@ -342,7 +342,7 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], diff --git a/package.json b/package.json index 5b95a4a9..960049be 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "typescript": "^5.9.3" }, "overrides": { - "minimatch": ">=10.2.1", + "minimatch": ">=10.2.3", "rollup": ">=4.59.0" } }