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" } } 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.ts deleted file mode 100644 index 15464415..00000000 --- a/src/api/writer-generator/typescript.ts +++ /dev/null @@ -1,1651 +0,0 @@ -import { - camelCase, - kebabCase, - pascalCase, - typeSchemaInfo, - uppercaseFirstLetter, - uppercaseFirstLetterOfEach, -} 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}`; -}; - -export type TypeScriptOptions = { - /** openResourceTypeSet -- for resource families (Resource, DomainResource) use open set for resourceType field. - * - * - when openResourceTypeSet is false: `type Resource = { resourceType: "Resource" | "DomainResource" | "Patient" }` - * - when openResourceTypeSet is true: `type Resource = { resourceType: "Resource" | "DomainResource" | "Patient" | string }` - */ - openResourceTypeSet: boolean; - primitiveTypeExtension: boolean; -} & WriterOptions; - -export class TypeScript extends Writer { - tsImportType(tsPackageName: string, ...entities: string[]) { - 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); - if (profiles.length > 0) { - this.lineSM(`export * from "./profiles"`); - } - - let exports = schemas - .flatMap((schema) => { - const resourceName = tsResourceName(schema.identifier); - const typeExports = isProfileTypeSchema(schema) - ? [] - : [ - resourceName, - ...((isResourceTypeSchema(schema) && schema.nested) || - (isLogicalTypeSchema(schema) && schema.nested) - ? schema.nested.map((n) => tsResourceName(n.identifier)) - : []), - ]; - const valueExports = isResourceTypeSchema(schema) ? [`is${resourceName}`] : []; - - return [ - { - identifier: schema.identifier, - tsPackageName: tsModuleName(schema.identifier), - resourceName, - typeExports, - valueExports, - }, - ]; - }) - .sort((a, b) => a.resourceName.localeCompare(b.resourceName)); - - // FIXME: actually, duplication may means internal error... - exports = Array.from(new Map(exports.map((exp) => [exp.resourceName.toLowerCase(), exp])).values()).sort( - (a, b) => a.resourceName.localeCompare(b.resourceName), - ); - - for (const exp of exports) { - this.debugComment(exp.identifier); - if (exp.typeExports.length > 0) { - this.lineSM(`export type { ${exp.typeExports.join(", ")} } from "./${exp.tsPackageName}"`); - } - if (exp.valueExports.length > 0) { - this.lineSM(`export { ${exp.valueExports.join(", ")} } from "./${exp.tsPackageName}"`); - } - } - }); - } - - generateDependenciesImports(tsIndex: TypeSchemaIndex, schema: RegularTypeSchema, importPrefix = "../") { - if (schema.dependencies) { - const imports = []; - const skipped = []; - for (const dep of schema.dependencies) { - if (["complex-type", "resource", "logical"].includes(dep.kind)) { - imports.push({ - tsPackage: `${importPrefix}${tsModulePath(dep)}`, - name: uppercaseFirstLetter(dep.name), - dep: dep, - }); - } else if (isNestedIdentifier(dep)) { - const ndep = { ...dep }; - ndep.name = canonicalToName(dep.url) as Name; - imports.push({ - tsPackage: `${importPrefix}${tsModulePath(ndep)}`, - name: tsResourceName(dep), - dep: dep, - }); - } else { - skipped.push(dep); - } - } - imports.sort((a, b) => a.name.localeCompare(b.name)); - for (const dep of imports) { - this.debugComment(dep.dep); - this.tsImportType(dep.tsPackage, dep.name); - } - for (const dep of skipped) { - this.debugComment("skip:", dep); - } - this.line(); - if ( - this.withPrimitiveTypeExtension(schema) && - schema.identifier.name !== "Element" && - schema.dependencies.find((e) => e.name === "Element") === undefined - ) { - const elementUrl = "http://hl7.org/fhir/StructureDefinition/Element" as CanonicalUrl; - const element = tsIndex.resolveByUrl(schema.identifier.package, elementUrl); - if (!element) throw new Error(`'${elementUrl}' not found for ${schema.identifier.package}.`); - - this.tsImportType(`${importPrefix}${tsModulePath(element.identifier)}`, "Element"); - } - } - } - - generateComplexTypeReexports(schema: RegularTypeSchema) { - const complexTypeDeps = schema.dependencies?.filter(isComplexTypeIdentifier).map((dep) => ({ - tsPackage: `../${tsModulePath(dep)}`, - name: uppercaseFirstLetter(dep.name), - })); - if (complexTypeDeps && complexTypeDeps.length > 0) { - for (const dep of complexTypeDeps) { - this.lineSM(`export type { ${dep.name} } from "${dep.tsPackage}"`); - } - this.line(); - } - } - - addFieldExtension(fieldName: string, isArray: boolean): void { - const extFieldName = tsFieldName(`_${fieldName}`); - const typeExpr = isArray ? "(Element | null)[]" : "Element"; - this.lineSM(`${extFieldName}?: ${typeExpr}`); - } - - generateType(tsIndex: TypeSchemaIndex, schema: RegularTypeSchema) { - let name: string; - // Generic types: Reference, Coding, CodeableConcept - const genericTypes = ["Reference", "Coding", "CodeableConcept"]; - if (genericTypes.includes(schema.identifier.name)) { - name = `${schema.identifier.name}`; - } else if (schema.identifier.kind === "nested") { - name = tsResourceName(schema.identifier); - } else { - name = tsResourceName(schema.identifier); - } - - let extendsClause: string | undefined; - if (schema.base) extendsClause = `extends ${canonicalToName(schema.base.url)}`; - - this.debugComment(schema.identifier); - if (!schema.fields && !extendsClause && !isResourceTypeSchema(schema)) { - this.lineSM(`export type ${name} = object`); - return; - } - this.curlyBlock(["export", "interface", name, extendsClause], () => { - if (isResourceTypeSchema(schema)) { - const possibleResourceTypes = [schema.identifier]; - possibleResourceTypes.push(...tsIndex.resourceChildren(schema.identifier)); - const openSetSuffix = - this.opts.openResourceTypeSet && possibleResourceTypes.length > 1 ? " | string" : ""; - this.lineSM( - `resourceType: ${possibleResourceTypes - .sort((a, b) => a.name.localeCompare(b.name)) - .map((e) => `"${e.name}"`) - .join(" | ")}${openSetSuffix}`, - ); - this.line(); - } - - if (!schema.fields) return; - const fields = Object.entries(schema.fields).sort((a, b) => a[0].localeCompare(b[0])); - - for (const [fieldName, field] of fields) { - if (isChoiceDeclarationField(field)) continue; - // Skip fields without type info (can happen with incomplete StructureDefinitions) - if (!field.type) continue; - - this.debugComment(fieldName, ":", field); - - const tsName = tsFieldName(fieldName); - const tsType = resolveFieldTsType(schema.identifier.name, tsName, field); - const optionalSymbol = field.required ? "" : "?"; - const arraySymbol = field.array ? "[]" : ""; - this.lineSM(`${tsName}${optionalSymbol}: ${tsType}${arraySymbol}`); - - if (this.withPrimitiveTypeExtension(schema)) { - if (isPrimitiveIdentifier(field.type)) { - this.addFieldExtension(fieldName, field.array ?? false); - } - } - } - }); - } - - withPrimitiveTypeExtension(schema: TypeSchema): boolean { - if (!this.opts.primitiveTypeExtension) return false; - if (!isSpecializationTypeSchema(schema)) return false; - for (const field of Object.values(schema.fields ?? {})) { - if (isChoiceDeclarationField(field)) continue; - if (isPrimitiveIdentifier(field.type)) return true; - } - return false; - } - - generateResourceTypePredicate(schema: RegularTypeSchema) { - if (!isResourceTypeSchema(schema)) return; - const name = tsResourceName(schema.identifier); - this.curlyBlock(["export", "const", `is${name}`, "=", `(resource: unknown): resource is ${name}`, "=>"], () => { - this.lineSM( - `return resource !== null && typeof resource === "object" && (resource as {resourceType: string}).resourceType === "${schema.identifier.name}"`, - ); - }); - } - - generateNestedTypes(tsIndex: TypeSchemaIndex, schema: RegularTypeSchema) { - if (schema.nested) { - for (const subtype of schema.nested) { - this.generateType(tsIndex, subtype); - this.line(); - } - } - } - - 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); - }); - }); - } else if (["complex-type", "resource", "logical"].includes(schema.identifier.kind)) { - this.cat(`${tsModuleFileName(schema.identifier)}`, () => { - this.generateDisclaimer(); - this.generateDependenciesImports(tsIndex, schema); - this.generateComplexTypeReexports(schema); - this.generateNestedTypes(tsIndex, schema); - this.comment( - "CanonicalURL:", - schema.identifier.url, - `(pkg: ${packageMetaToFhir(packageMeta(schema))})`, - ); - this.generateType(tsIndex, schema); - this.generateResourceTypePredicate(schema); - }); - } else { - throw new Error(`Profile generation not implemented for kind: ${schema.identifier.kind}`); - } - } - - override async generate(tsIndex: TypeSchemaIndex) { - // Only generate code for schemas from focused packages - const typesToGenerate = [ - ...tsIndex.collectComplexTypes(), - ...tsIndex.collectResources(), - ...tsIndex.collectLogicalModels(), - ...(this.opts.generateProfile ? tsIndex.collectProfiles() : []), - ]; - const grouped = groupByPackages(typesToGenerate); - - const hasProfiles = this.opts.generateProfile && typesToGenerate.some(isProfileTypeSchema); - - this.cd("/", () => { - if (hasProfiles) { - this.generateProfileHelpersModule(); - } - - for (const [packageName, packageSchemas] of Object.entries(grouped)) { - const tsPackageDir = tsFhirPackageDir(packageName); - this.cd(tsPackageDir, () => { - for (const schema of packageSchemas) { - this.generateResourceModule(tsIndex, schema); - } - this.generateProfileIndexFile(tsIndex, packageSchemas.filter(isProfileTypeSchema)); - this.generateFhirPackageIndexFile(packageSchemas); - }); - } - }); - } -} diff --git a/src/api/writer-generator/typescript/name.ts b/src/api/writer-generator/typescript/name.ts new file mode 100644 index 00000000..e27feeb6 --- /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 +const tsKeywords = new Set([ "class", "function", "return", "if", "for", "while", "const", "let", "var", "import", "export", "interface" ]); + +const normalizeTsName = (n: string): string => { + if (tsKeywords.has(n)) n = `${n}_`; + return n.replace(/\[x\]/g, "_x_").replace(/[- :.]/g, "_"); +}; + +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 tsPackageDir = (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 `${tsPackageDir(id.package)}/${tsModuleName(id)}`; +}; + +export const tsNameFromCanonical = (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 = tsCamelCase(sliceName); + return `set${uppercaseFirstLetter(normalized || "Slice")}`; +}; + +export const tsExtensionMethodName = (name: string): string => { + const normalized = tsCamelCase(name); + return `set${uppercaseFirstLetter(normalized || "Extension")}`; +}; + +export const tsQualifiedExtensionMethodName = (name: string, path?: string): string => { + const rawPath = + path + ?.split(".") + .filter((p) => p && p !== "extension") + .join("_") ?? ""; + 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(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 new file mode 100644 index 00000000..e46ac9f9 --- /dev/null +++ b/src/api/writer-generator/typescript/profile.ts @@ -0,0 +1,1037 @@ +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 { + tsCamelCase, + tsExtensionInputTypeName, + tsExtensionMethodName, + tsFieldName, + tsModulePath, + tsNameFromCanonical, + tsPackageDir, + tsProfileClassName, + tsProfileModuleName, + tsQualifiedExtensionMethodName, + tsQualifiedSliceMethodName, + tsResourceName, + tsSliceInputTypeName, + tsSliceMethodName, +} from "./name"; +import { resolveFieldTsType, resolvePrimitiveType, tsEnumType, tsGet, tsTypeFromIdentifier } from "./utils"; +import type { TypeScript } from "./writer"; + +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 }; +}; + +export const generateProfileIndexFile = ( + w: TypeScript, + tsIndex: TypeSchemaIndex, + initialProfiles: ProfileTypeSchema[], +) => { + if (initialProfiles.length === 0) return; + 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(w, 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) { + w.lineSM(exp); + } + }); + }); +}; + +const tsTypeForProfileField = ( + _w: 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 generateProfileHelpersModule = (w: TypeScript) => { + w.cat("profile-helpers.ts", () => { + w.generateDisclaimer(); + w.curlyBlock( + ["export const", "isRecord", "=", "(value: unknown): value is Record", "=>"], + () => { + w.lineSM('return value !== null && typeof value === "object" && !Array.isArray(value)'); + }, + ); + w.line(); + w.curlyBlock( + [ + "export const", + "getOrCreateObjectAtPath", + "=", + "(root: Record, path: string[]): Record", + "=>", + ], + () => { + w.lineSM("let current: Record = root"); + w.curlyBlock(["for (const", "segment", "of", "path)"], () => { + w.curlyBlock(["if", "(Array.isArray(current[segment]))"], () => { + w.lineSM("const list = current[segment] as unknown[]"); + w.curlyBlock(["if", "(list.length === 0)"], () => { + w.lineSM("list.push({})"); + }); + w.lineSM("current = list[0] as Record"); + }); + w.curlyBlock(["else"], () => { + w.curlyBlock(["if", "(!isRecord(current[segment]))"], () => { + w.lineSM("current[segment] = {}"); + }); + w.lineSM("current = current[segment] as Record"); + }); + }); + w.lineSM("return current"); + }, + ); + w.line(); + w.curlyBlock( + [ + "export const", + "mergeMatch", + "=", + "(target: Record, match: Record): void", + "=>", + ], + () => { + w.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { + w.curlyBlock( + ["if", '(key === "__proto__" || key === "constructor" || key === "prototype")'], + () => { + w.lineSM("continue"); + }, + ); + w.curlyBlock(["if", "(isRecord(matchValue))"], () => { + w.curlyBlock(["if", "(isRecord(target[key]))"], () => { + w.lineSM("mergeMatch(target[key] as Record, matchValue)"); + }); + w.curlyBlock(["else"], () => { + w.lineSM("target[key] = { ...matchValue }"); + }); + }); + w.curlyBlock(["else"], () => { + w.lineSM("target[key] = matchValue"); + }); + }); + }, + ); + w.line(); + w.curlyBlock( + [ + "export const", + "applySliceMatch", + "=", + ">(input: T, match: Record): T", + "=>", + ], + () => { + w.lineSM("const result = { ...input } as Record"); + w.lineSM("mergeMatch(result, match)"); + w.lineSM("return result as T"); + }, + ); + w.line(); + w.curlyBlock(["export const", "matchesValue", "=", "(value: unknown, match: unknown): boolean", "=>"], () => { + w.curlyBlock(["if", "(Array.isArray(match))"], () => { + w.curlyBlock(["if", "(!Array.isArray(value))"], () => w.lineSM("return false")); + w.lineSM("return match.every((matchItem) => value.some((item) => matchesValue(item, matchItem)))"); + }); + w.curlyBlock(["if", "(isRecord(match))"], () => { + w.curlyBlock(["if", "(!isRecord(value))"], () => w.lineSM("return false")); + w.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { + w.curlyBlock(["if", "(!matchesValue((value as Record)[key], matchValue))"], () => { + w.lineSM("return false"); + }); + }); + w.lineSM("return true"); + }); + w.lineSM("return value === match"); + }); + w.line(); + w.curlyBlock( + ["export const", "matchesSlice", "=", "(value: unknown, match: Record): boolean", "=>"], + () => { + w.lineSM("return matchesValue(value, match)"); + }, + ); + w.line(); + // extractComplexExtension - extract sub-extension values from complex extension + w.curlyBlock( + [ + "export const", + "extractComplexExtension", + "=", + "(extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, config: Array<{ name: string; valueField: string; isArray: boolean }>): Record | undefined", + "=>", + ], + () => { + w.lineSM("if (!extension?.extension) return undefined"); + w.lineSM("const result: Record = {}"); + w.curlyBlock(["for (const", "{ name, valueField, isArray }", "of", "config)"], () => { + w.lineSM("const subExts = extension.extension.filter(e => e.url === name)"); + w.curlyBlock(["if", "(isArray)"], () => { + w.lineSM("result[name] = subExts.map(e => (e as Record)[valueField])"); + }); + w.curlyBlock(["else if", "(subExts[0])"], () => { + w.lineSM("result[name] = (subExts[0] as Record)[valueField]"); + }); + }); + w.lineSM("return result"); + }, + ); + w.line(); + // extractSliceSimplified - remove match keys from slice (reverse of applySliceMatch) + w.curlyBlock( + [ + "export const", + "extractSliceSimplified", + "=", + ">(slice: T, matchKeys: string[]): Partial", + "=>", + ], + () => { + w.lineSM("const result = { ...slice } as Record"); + w.curlyBlock(["for (const", "key", "of", "matchKeys)"], () => { + w.lineSM("delete result[key]"); + }); + w.lineSM("return result as Partial"); + }, + ); + }); +}; + +const generateProfileHelpersImport = ( + w: 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) { + w.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 = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { + const usedTypes = new Map(); + + const getModulePath = (typeId: Identifier): string => { + if (isNestedIdentifier(typeId)) { + const path = tsNameFromCanonical(typeId.url, true); + if (path) return `../../${tsPackageDir(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) { + w.tsImportType(importPath, tsName); + } + if (sortedImports.length > 0) w.line(); +}; + +export const generateProfileClass = ( + w: 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); + 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; + w.lineSM(`${sub.name}${isRequired ? "" : "?"}: ${tsType}${isArray ? "[]" : ""}`); + } + }); + w.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>`; + } + w.lineSM(`export type ${typeName} = ${typeExpr}`); + } + w.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(w, { + needsGetOrCreateObjectAtPath, + needsSliceHelpers, + needsExtensionExtraction, + needsSliceExtraction, + }); + w.line(); + } + + // Check if we have an override interface (narrowed types) + const hasOverrideInterface = detectFieldOverrides(w, tsIndex, flatProfile).size > 0; + const factoryInfo = collectProfileFactoryInfo(flatProfile); + + const hasParams = factoryInfo.params.length > 0; + const 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) { + w.curlyBlock(["export", "type", createArgsTypeName, "="], () => { + for (const p of factoryInfo.params) { + w.lineSM(`${p.name}: ${p.tsType}`); + } + }); + w.line(); + } + + if (schema) { + w.comment("CanonicalURL:", schema.identifier.url, `(pkg: ${packageMetaToFhir(packageMeta(schema))})`); + } + w.curlyBlock(["export", "class", profileClassName], () => { + w.line(`private resource: ${tsBaseResourceName}`); + w.line(); + w.curlyBlock(["constructor", `(resource: ${tsBaseResourceName})`], () => { + w.line("this.resource = resource"); + }); + w.line(); + w.curlyBlock(["static", "from", `(resource: ${tsBaseResourceName})`, `: ${profileClassName}`], () => { + w.line(`return new ${profileClassName}(resource)`); + }); + w.line(); + w.curlyBlock(["static", "createResource", `(${paramSignature})`, `: ${tsBaseResourceName}`], () => { + w.curlyBlock([`const resource: ${tsBaseResourceName} =`], () => { + for (const f of allFields) { + w.line(`${f.name}: ${f.value},`); + } + }, [` as unknown as ${tsBaseResourceName}`]); + w.line("return resource"); + }); + w.line(); + w.curlyBlock(["static", "create", `(${paramSignature})`, `: ${profileClassName}`], () => { + w.line(`return ${profileClassName}.from(${profileClassName}.createResource(${hasParams ? "args" : ""}))`); + }); + w.line(); + // toResource() returns base type (e.g., Patient) + w.curlyBlock(["toResource", "()", `: ${tsBaseResourceName}`], () => { + w.line("return this.resource"); + }); + w.line(); + // Getter and setter methods for required profile fields + for (const p of factoryInfo.params) { + const methodSuffix = uppercaseFirstLetter(p.name); + w.curlyBlock([`get${methodSuffix}`, "()", `: ${p.tsType} | undefined`], () => { + w.line(`return this.resource.${p.name} as ${p.tsType} | undefined`); + }); + w.line(); + w.curlyBlock([`set${methodSuffix}`, `(value: ${p.tsType})`, ": this"], () => { + w.line(`(this.resource as any).${p.name} = value`); + w.line("return this"); + }); + w.line(); + } + // toProfile() returns casted profile type if override interface exists + if (hasOverrideInterface) { + w.curlyBlock(["toProfile", "()", `: ${tsProfileName}`], () => { + w.line(`return this.resource as ${tsProfileName}`); + }); + w.line(); + } + + const extensionMethods = extensions + .filter((ext) => ext.url) + .map((ext) => ({ + ext, + baseName: tsExtensionMethodName(ext.name), + fallbackName: tsQualifiedExtensionMethodName(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 = tsQualifiedSliceMethodName(slice.fieldName, slice.sliceName); + return [slice, needsFallback ? fallback : baseName]; + }), + ); + + generateExtensionSetterMethods(w, extensions, extensionMethodNames, tsProfileName); + + for (const sliceDef of sliceDefs) { + const methodName = + sliceMethodNames.get(sliceDef) ?? tsQualifiedSliceMethodName(sliceDef.fieldName, sliceDef.sliceName); + const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); + const matchLiteral = JSON.stringify(sliceDef.match); + const 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`; + w.curlyBlock(["public", methodName, paramSignature], () => { + w.line(`const match = ${matchLiteral} as Record`); + // Use empty object as default when input is optional + const inputExpr = sliceDef.inputOptional + ? "(input ?? {}) as Record" + : "input as Record"; + w.line(`const value = applySliceMatch(${inputExpr}, match) as unknown as ${sliceDef.baseType}`); + if (sliceDef.array) { + w.line(`const list = (${fieldAccess} ??= [])`); + w.line("const index = list.findIndex((item) => matchesSlice(item, match))"); + w.line("if (index === -1) {"); + w.indentBlock(() => { + w.line("list.push(value)"); + }); + w.line("} else {"); + w.indentBlock(() => { + w.line("list[index] = value"); + }); + w.line("}"); + } else { + w.line(`${fieldAccess} = value`); + } + w.line("return this"); + }); + w.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(tsCamelCase(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) { + w.line(`const ext = this.resource.extension?.find(e => e.url === "${ext.url}")`); + } else { + w.line( + `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + ); + w.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 + w.curlyBlock(["public", getMethodName, `(): ${inputTypeName} | undefined`], () => { + generateExtLookup(); + 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} }`; + }); + w.line(`const config = [${configItems.join(", ")}]`); + w.line( + `return extractComplexExtension(ext as unknown as { extension?: Array<{ url?: string; [key: string]: unknown }> }, config) as ${inputTypeName}`, + ); + }); + w.line(); + // Raw Extension getter + w.curlyBlock(["public", getExtensionMethodName, "(): Extension | undefined"], () => { + generateExtLookup(); + 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) + w.curlyBlock(["public", getMethodName, `(): ${valueType} | undefined`], () => { + generateExtLookup(); + w.line( + `return (ext as Record | undefined)?.${valueField} as ${valueType} | undefined`, + ); + }); + w.line(); + // Raw Extension getter + w.curlyBlock(["public", getExtensionMethodName, "(): Extension | undefined"], () => { + generateExtLookup(); + w.line("return ext"); + }); + } else { + // Generic extension - only raw getter makes sense + w.curlyBlock(["public", getMethodName, "(): Extension | undefined"], () => { + if (targetPath.length === 0) { + w.line(`return this.resource.extension?.find(e => e.url === "${ext.url}")`); + } else { + w.line( + `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + ); + w.line( + `return (target.extension as Extension[] | undefined)?.find(e => e.url === "${ext.url}")`, + ); + } + }); + } + w.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(tsCamelCase(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 = () => { + w.line(`const match = ${matchLiteral} as Record`); + if (sliceDef.array) { + w.line(`const list = ${fieldAccess}`); + w.line("if (!list) return undefined"); + w.line("const item = list.find((item) => matchesSlice(item, match))"); + } else { + w.line(`const item = ${fieldAccess}`); + w.line("if (!item || !matchesSlice(item, match)) return undefined"); + } + }; + + // Flat API getter (simplified) + w.curlyBlock(["public", getMethodName, `(): ${typeName} | undefined`], () => { + generateSliceLookup(); + if (sliceDef.array) { + w.line("if (!item) return undefined"); + } + w.line( + `return extractSliceSimplified(item as unknown as Record, ${matchKeys}) as ${typeName}`, + ); + }); + w.line(); + + // Raw getter (full FHIR type) + w.curlyBlock(["public", getRawMethodName, `(): ${baseType} | undefined`], () => { + generateSliceLookup(); + if (sliceDef.array) { + w.line("return item"); + } else { + w.line("return item"); + } + }); + w.line(); + } + }); + w.line(); +}; + +const generateExtensionSetterMethods = ( + w: TypeScript, + extensions: ProfileExtension[], + extensionMethodNames: Map, + tsProfileName: string, +) => { + for (const ext of extensions) { + if (!ext.url) continue; + const methodName = extensionMethodNames.get(ext) ?? tsQualifiedExtensionMethodName(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); + 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 === "*") { + 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 { + w.curlyBlock(["if", `(input.${sub.name} !== undefined)`], () => { + w.line( + `subExtensions.push({ url: "${sub.url}", ${valueField}: input.${sub.name} }${pushSuffix})`, + ); + }); + } + } + if (targetPath.length === 0) { + w.line("const list = (this.resource.extension ??= [])"); + w.line(`list.push({ url: "${ext.url}", extension: subExtensions })`); + } else { + w.line( + `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + ); + w.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); + w.line(`(target.extension as Extension[]).push({ url: "${ext.url}", extension: subExtensions })`); + } + 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)}`; + 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) { + w.line("const list = (this.resource.extension ??= [])"); + w.line(`list.push(${extLiteral})`); + } else { + w.line( + `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( + targetPath, + )})`, + ); + w.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); + w.line(`(target.extension as Extension[]).push(${extLiteral})`); + } + w.line("return this"); + }); + } else { + w.curlyBlock(["public", methodName, `(value: Omit): this`], () => { + if (targetPath.length === 0) { + w.line("const list = (this.resource.extension ??= [])"); + w.line(`list.push({ url: "${ext.url}", ...value })`); + } else { + w.line( + `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( + targetPath, + )})`, + ); + w.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); + w.line(`(target.extension as Extension[]).push({ url: "${ext.url}", ...value })`); + } + w.line("return this"); + }); + } + w.line(); + } +}; + +const detectFieldOverrides = ( + w: 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(w, tsIndex, flatProfile, fieldName, pField); + overrides.set(fieldName, { + profileType: tsType, + required: true, + array: pField.array ?? false, + }); + } + } + return overrides; +}; + +export const generateProfileOverrideInterface = ( + w: TypeScript, + tsIndex: TypeSchemaIndex, + flatProfile: ProfileTypeSchema, +) => { + const overrides = detectFieldOverrides(w, tsIndex, flatProfile); + if (overrides.size === 0) return; + + const tsProfileName = tsResourceName(flatProfile.identifier); + const tsBaseResourceName = tsResourceName(flatProfile.base); + + 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 ? "[]" : ""; + w.lineSM(`${tsField}${optionalSymbol}: ${override.profileType}${arraySymbol}`); + } + }); + w.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..aa9e1382 --- /dev/null +++ b/src/api/writer-generator/typescript/utils.ts @@ -0,0 +1,90 @@ +import { + type ChoiceFieldInstance, + type EnumDefinition, + type Identifier, + isNestedIdentifier, + isPrimitiveIdentifier, + type RegularField, +} from "@root/typeschema/types"; +import { tsResourceName } from "./name"; + +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 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})`; +}; + +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; +}; diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts new file mode 100644 index 00000000..dc335fe9 --- /dev/null +++ b/src/api/writer-generator/typescript/writer.ts @@ -0,0 +1,317 @@ +import { uppercaseFirstLetter } from "@root/api/writer-generator/utils"; +import { Writer, type WriterOptions } from "@root/api/writer-generator/writer"; +import { + type CanonicalUrl, + isChoiceDeclarationField, + isComplexTypeIdentifier, + isLogicalTypeSchema, + isNestedIdentifier, + isPrimitiveIdentifier, + isProfileTypeSchema, + isResourceTypeSchema, + isSpecializationTypeSchema, + type Name, + packageMeta, + packageMetaToFhir, + type RegularTypeSchema, + type TypeSchema, +} from "@root/typeschema/types"; +import { groupByPackages, type TypeSchemaIndex } from "@root/typeschema/utils"; +import { + tsFieldName, + tsModuleFileName, + tsModuleName, + tsModulePath, + tsNameFromCanonical, + tsPackageDir, + tsProfileModuleFileName, + tsResourceName, +} 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. + * + * - when openResourceTypeSet is false: `type Resource = { resourceType: "Resource" | "DomainResource" | "Patient" }` + * - when openResourceTypeSet is true: `type Resource = { resourceType: "Resource" | "DomainResource" | "Patient" | string }` + */ + openResourceTypeSet: boolean; + primitiveTypeExtension: boolean; +} & WriterOptions; + +export class TypeScript extends Writer { + tsImportType(tsPackageName: string, ...entities: string[]) { + this.lineSM(`import type { ${entities.join(", ")} } from "${tsPackageName}"`); + } + + generateFhirPackageIndexFile(schemas: TypeSchema[]) { + this.cat("index.ts", () => { + const profiles = schemas.filter(isProfileTypeSchema); + if (profiles.length > 0) { + this.lineSM(`export * from "./profiles"`); + } + + let exports = schemas + .flatMap((schema) => { + const resourceName = tsResourceName(schema.identifier); + const typeExports = isProfileTypeSchema(schema) + ? [] + : [ + resourceName, + ...((isResourceTypeSchema(schema) && schema.nested) || + (isLogicalTypeSchema(schema) && schema.nested) + ? schema.nested.map((n) => tsResourceName(n.identifier)) + : []), + ]; + const valueExports = isResourceTypeSchema(schema) ? [`is${resourceName}`] : []; + + return [ + { + identifier: schema.identifier, + tsPackageName: tsModuleName(schema.identifier), + resourceName, + typeExports, + valueExports, + }, + ]; + }) + .sort((a, b) => a.resourceName.localeCompare(b.resourceName)); + + // FIXME: actually, duplication may means internal error... + exports = Array.from(new Map(exports.map((exp) => [exp.resourceName.toLowerCase(), exp])).values()).sort( + (a, b) => a.resourceName.localeCompare(b.resourceName), + ); + + for (const exp of exports) { + this.debugComment(exp.identifier); + if (exp.typeExports.length > 0) { + this.lineSM(`export type { ${exp.typeExports.join(", ")} } from "./${exp.tsPackageName}"`); + } + if (exp.valueExports.length > 0) { + this.lineSM(`export { ${exp.valueExports.join(", ")} } from "./${exp.tsPackageName}"`); + } + } + }); + } + + generateDependenciesImports(tsIndex: TypeSchemaIndex, schema: RegularTypeSchema, importPrefix = "../") { + if (schema.dependencies) { + const imports = []; + const skipped = []; + for (const dep of schema.dependencies) { + if (["complex-type", "resource", "logical"].includes(dep.kind)) { + imports.push({ + tsPackage: `${importPrefix}${tsModulePath(dep)}`, + name: uppercaseFirstLetter(dep.name), + dep: dep, + }); + } else if (isNestedIdentifier(dep)) { + const ndep = { ...dep }; + ndep.name = tsNameFromCanonical(dep.url) as Name; + imports.push({ + tsPackage: `${importPrefix}${tsModulePath(ndep)}`, + name: tsResourceName(dep), + dep: dep, + }); + } else { + skipped.push(dep); + } + } + imports.sort((a, b) => a.name.localeCompare(b.name)); + for (const dep of imports) { + this.debugComment(dep.dep); + this.tsImportType(dep.tsPackage, dep.name); + } + for (const dep of skipped) { + this.debugComment("skip:", dep); + } + this.line(); + if ( + this.withPrimitiveTypeExtension(schema) && + schema.identifier.name !== "Element" && + schema.dependencies.find((e) => e.name === "Element") === undefined + ) { + const elementUrl = "http://hl7.org/fhir/StructureDefinition/Element" as CanonicalUrl; + const element = tsIndex.resolveByUrl(schema.identifier.package, elementUrl); + if (!element) throw new Error(`'${elementUrl}' not found for ${schema.identifier.package}.`); + + this.tsImportType(`${importPrefix}${tsModulePath(element.identifier)}`, "Element"); + } + } + } + + generateComplexTypeReexports(schema: RegularTypeSchema) { + const complexTypeDeps = schema.dependencies?.filter(isComplexTypeIdentifier).map((dep) => ({ + tsPackage: `../${tsModulePath(dep)}`, + name: uppercaseFirstLetter(dep.name), + })); + if (complexTypeDeps && complexTypeDeps.length > 0) { + for (const dep of complexTypeDeps) { + this.lineSM(`export type { ${dep.name} } from "${dep.tsPackage}"`); + } + this.line(); + } + } + + addFieldExtension(fieldName: string, isArray: boolean): void { + const extFieldName = tsFieldName(`_${fieldName}`); + const typeExpr = isArray ? "(Element | null)[]" : "Element"; + this.lineSM(`${extFieldName}?: ${typeExpr}`); + } + + generateType(tsIndex: TypeSchemaIndex, schema: RegularTypeSchema) { + let name: string; + // Generic types: Reference, Coding, CodeableConcept + const genericTypes = ["Reference", "Coding", "CodeableConcept"]; + if (genericTypes.includes(schema.identifier.name)) { + name = `${schema.identifier.name}`; + } else if (schema.identifier.kind === "nested") { + name = tsResourceName(schema.identifier); + } else { + name = tsResourceName(schema.identifier); + } + + let extendsClause: string | undefined; + if (schema.base) extendsClause = `extends ${tsNameFromCanonical(schema.base.url)}`; + + this.debugComment(schema.identifier); + if (!schema.fields && !extendsClause && !isResourceTypeSchema(schema)) { + this.lineSM(`export type ${name} = object`); + return; + } + this.curlyBlock(["export", "interface", name, extendsClause], () => { + if (isResourceTypeSchema(schema)) { + const possibleResourceTypes = [schema.identifier]; + possibleResourceTypes.push(...tsIndex.resourceChildren(schema.identifier)); + const openSetSuffix = + this.opts.openResourceTypeSet && possibleResourceTypes.length > 1 ? " | string" : ""; + this.lineSM( + `resourceType: ${possibleResourceTypes + .sort((a, b) => a.name.localeCompare(b.name)) + .map((e) => `"${e.name}"`) + .join(" | ")}${openSetSuffix}`, + ); + this.line(); + } + + if (!schema.fields) return; + const fields = Object.entries(schema.fields).sort((a, b) => a[0].localeCompare(b[0])); + + for (const [fieldName, field] of fields) { + if (isChoiceDeclarationField(field)) continue; + // Skip fields without type info (can happen with incomplete StructureDefinitions) + if (!field.type) continue; + + this.debugComment(fieldName, ":", field); + + const tsName = tsFieldName(fieldName); + const tsType = resolveFieldTsType(schema.identifier.name, tsName, field); + const optionalSymbol = field.required ? "" : "?"; + const arraySymbol = field.array ? "[]" : ""; + this.lineSM(`${tsName}${optionalSymbol}: ${tsType}${arraySymbol}`); + + if (this.withPrimitiveTypeExtension(schema)) { + if (isPrimitiveIdentifier(field.type)) { + this.addFieldExtension(fieldName, field.array ?? false); + } + } + } + }); + } + + withPrimitiveTypeExtension(schema: TypeSchema): boolean { + if (!this.opts.primitiveTypeExtension) return false; + if (!isSpecializationTypeSchema(schema)) return false; + for (const field of Object.values(schema.fields ?? {})) { + if (isChoiceDeclarationField(field)) continue; + if (isPrimitiveIdentifier(field.type)) return true; + } + return false; + } + + generateResourceTypePredicate(schema: RegularTypeSchema) { + if (!isResourceTypeSchema(schema)) return; + const name = tsResourceName(schema.identifier); + this.curlyBlock(["export", "const", `is${name}`, "=", `(resource: unknown): resource is ${name}`, "=>"], () => { + this.lineSM( + `return resource !== null && typeof resource === "object" && (resource as {resourceType: string}).resourceType === "${schema.identifier.name}"`, + ); + }); + } + + generateNestedTypes(tsIndex: TypeSchemaIndex, schema: RegularTypeSchema) { + if (schema.nested) { + for (const subtype of schema.nested) { + this.generateType(tsIndex, subtype); + 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); + generateProfileImports(this, tsIndex, flatProfile); + generateProfileOverrideInterface(this, tsIndex, flatProfile); + generateProfileClass(this, tsIndex, flatProfile, schema); + }); + }); + } else if (["complex-type", "resource", "logical"].includes(schema.identifier.kind)) { + this.cat(`${tsModuleFileName(schema.identifier)}`, () => { + this.generateDisclaimer(); + this.generateDependenciesImports(tsIndex, schema); + this.generateComplexTypeReexports(schema); + this.generateNestedTypes(tsIndex, schema); + this.comment( + "CanonicalURL:", + schema.identifier.url, + `(pkg: ${packageMetaToFhir(packageMeta(schema))})`, + ); + this.generateType(tsIndex, schema); + this.generateResourceTypePredicate(schema); + }); + } else { + throw new Error(`Profile generation not implemented for kind: ${schema.identifier.kind}`); + } + } + + override async generate(tsIndex: TypeSchemaIndex) { + // Only generate code for schemas from focused packages + const typesToGenerate = [ + ...tsIndex.collectComplexTypes(), + ...tsIndex.collectResources(), + ...tsIndex.collectLogicalModels(), + ...(this.opts.generateProfile ? tsIndex.collectProfiles() : []), + ]; + const grouped = groupByPackages(typesToGenerate); + + const hasProfiles = this.opts.generateProfile && typesToGenerate.some(isProfileTypeSchema); + + this.cd("/", () => { + if (hasProfiles) { + generateProfileHelpersModule(this); + } + + for (const [packageName, packageSchemas] of Object.entries(grouped)) { + const packageDir = tsPackageDir(packageName); + this.cd(packageDir, () => { + for (const schema of packageSchemas) { + this.generateResourceModule(tsIndex, schema); + } + generateProfileIndexFile(this, tsIndex, packageSchemas.filter(isProfileTypeSchema)); + this.generateFhirPackageIndexFile(packageSchemas); + }); + } + }); + } +}