From b8dff5b608b253b61e89c05063b3caea7c78ce12 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 18 Mar 2026 12:00:32 +0100 Subject: [PATCH 1/7] Add type discriminator support for slicing Compute resourceType-based match for type discriminators so slices discriminated by resource type (e.g. Bundle.entry by resource) generate getter/setter methods. Type-discriminated getters return items as-is (no stripMatchKeys) and input types don't Omit match fields. --- .../typescript/profile-slices.ts | 8 ++++- .../writer-generator/typescript/profile.ts | 4 +-- src/typeschema/core/field-builder.ts | 35 ++++++++++++++++--- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/api/writer-generator/typescript/profile-slices.ts b/src/api/writer-generator/typescript/profile-slices.ts index cda3488c7..5f1a378e0 100644 --- a/src/api/writer-generator/typescript/profile-slices.ts +++ b/src/api/writer-generator/typescript/profile-slices.ts @@ -67,6 +67,8 @@ export type SliceDef = { excluded: string[]; array: boolean; constrainedChoice: ConstrainedChoiceInfo | undefined; + /** True when the slice uses a type discriminator (match by resourceType) */ + typeDiscriminator: boolean; }; export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema): SliceDef[] => @@ -77,6 +79,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT const baseType = tsTypeFromIdentifier(field.type); const pkgName = flatProfile.identifier.package; const choiceBaseNames = collectChoiceBaseNames(tsIndex, field.type); + const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false; return Object.entries(field.slicing.slices) .filter(([_, slice]) => Object.keys(slice.match ?? {}).length > 0) .map(([sliceName, slice]) => { @@ -98,6 +101,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT excluded: slice.excluded ?? [], array: Boolean(field.array), constrainedChoice, + typeDiscriminator: isTypeDisc, }; }); }); @@ -193,7 +197,9 @@ export const generateSliceGetters = ( w.line("if (!item || !matchesValue(item, match)) return undefined"); } w.line("if (mode === 'raw') return item"); - if (sliceDef.constrainedChoice) { + if (sliceDef.typeDiscriminator) { + w.line(`return item as ${typeName}`); + } else if (sliceDef.constrainedChoice) { const cc = sliceDef.constrainedChoice; w.line(`return unwrapSliceChoice<${typeName}>(item, ${matchKeys}, ${JSON.stringify(cc.variant)})`); } else { diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 749266c10..889c804e1 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -225,7 +225,7 @@ const generateProfileHelpersImport = ( imports.push("applySliceMatch", "matchesValue", "setArraySlice", "getArraySlice", "ensureSliceDefaults"); if (extensions.some((ext) => ext.path.split(".").some((s) => s !== "extension"))) imports.push("ensurePath"); if (extensions.some((ext) => ext.isComplex && ext.subExtensions)) imports.push("extractComplexExtension"); - if (sliceDefs.length > 0) imports.push("stripMatchKeys"); + if (sliceDefs.some((s) => !s.typeDiscriminator)) imports.push("stripMatchKeys"); if (sliceDefs.some((s) => s.constrainedChoice)) imports.push("wrapSliceChoice", "unwrapSliceChoice"); if (extensions.some((ext) => ext.url)) imports.push("isExtension", "getExtensionValue", "pushExtension"); if (Object.keys(flatProfile.fields ?? {}).length > 0) @@ -564,7 +564,7 @@ const generateSliceInputTypes = (w: TypeScript, flatProfile: ProfileTypeSchema, const tsProfileName = tsResourceName(flatProfile.identifier); for (const sliceDef of sliceDefs) { const typeName = tsSliceFlatTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); - const matchFields = Object.keys(sliceDef.match); + const matchFields = sliceDef.typeDiscriminator ? [] : Object.keys(sliceDef.match); const allExcluded = [...new Set([...sliceDef.excluded, ...matchFields])]; if (sliceDef.constrainedChoice) { const cc = sliceDef.constrainedChoice; diff --git a/src/typeschema/core/field-builder.ts b/src/typeschema/core/field-builder.ts index 60e189ea2..1334f3b3d 100644 --- a/src/typeschema/core/field-builder.ts +++ b/src/typeschema/core/field-builder.ts @@ -172,21 +172,48 @@ const collectDiscriminatorValue = ( collectDiscriminatorValue(element, segments, index + 1, result); }; +/** + * For type discriminators, navigate the discriminator path through schema.elements + * and read the `type` field. If type is a simple name (not a URL), treat as FHIR + * resource type and set `{ : { resourceType: "" } }`. + */ +const computeTypeDiscriminatorMatch = ( + path: string, + schema: FHIRSchemaElement, + result: Record, +): void => { + if (path === "$this") return; + const segments = path.split("."); + let elem: FHIRSchemaElement | undefined = schema; + for (const seg of segments) { + elem = elem?.elements?.[seg]; + if (!elem) return; + } + const typeName = elem.type; + if (!typeName || typeName.includes("/")) return; + setNestedValue(result, segments, { resourceType: typeName }); +}; + /** * Computes match values by navigating the slice's schema elements along discriminator paths. * Used when a slice has an empty match but the discriminator values are nested deeper * (e.g., component slices in BP where the discriminator crosses a nested slicing boundary). */ const computeMatchFromSchema = ( - discriminators: Array<{ path: string }>, + discriminators: Array<{ type?: string; path: string }>, schema: FHIRSchemaElement | undefined, ): Record | undefined => { - if (!schema?.elements || !discriminators || discriminators.length === 0) return undefined; + if (!schema || !discriminators || discriminators.length === 0) return undefined; const result: Record = {}; for (const disc of discriminators) { - const segments = disc.path.split("."); - collectDiscriminatorValue(schema, segments, 0, result); + if (disc.type === "type") { + computeTypeDiscriminatorMatch(disc.path, schema, result); + } else { + if (!schema.elements) continue; + const segments = disc.path.split("."); + collectDiscriminatorValue(schema, segments, 0, result); + } } return Object.keys(result).length > 0 ? result : undefined; }; From 85438d515b7685eb49d419359a78b8db72013007 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 18 Mar 2026 12:00:36 +0100 Subject: [PATCH 2/7] Update slices design doc for type discriminator support --- docs/design/slices.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/slices.md b/docs/design/slices.md index cae6d5ac6..17e7c311d 100644 --- a/docs/design/slices.md +++ b/docs/design/slices.md @@ -118,7 +118,7 @@ bp.validate(); - No compile-time enforcement of slice constraints - No dedicated types for slice elements (see Refine below) - Array ordering is not enforced by the setter -- Only `value`/`pattern` discriminator types are supported; `type`, `profile`, and `exists` discriminators are not yet implemented +- Only `value`/`pattern`/`type` (resource type) discriminator types are supported; `profile` and `exists` discriminators are not yet implemented ## Refine (Not Implemented) @@ -154,7 +154,7 @@ class USCoreBloodPressureProfile { |---|---|---| | `value` | Supported | Fixed value matching (most common) | | `pattern` | Supported | Pattern matching on element | -| `type` | Not supported | Discriminate by element type | +| `type` | Partial | Resource type discrimination (by `resourceType` field) | | `profile` | Not supported | Discriminate by profile URL | | `exists` | Not supported | Discriminate by field presence | From d1f74789a470dc3de72cb6f5cf9318673fddd155 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 18 Mar 2026 12:00:44 +0100 Subject: [PATCH 3/7] Add snapshot test for type-discriminated profile and deduplicate test SDs Point local-package test at examples/local-package-folder/structure-definitions instead of maintaining a separate copy in test/assets. --- .../__snapshots__/local-package.test.ts.snap | 166 +++++++++++++++++ .../multi-package/local-package.test.ts | 29 ++- .../example-notebook.structuredefinition.json | 175 ------------------ 3 files changed, 194 insertions(+), 176 deletions(-) delete mode 100644 test/assets/local-package/structure-definitions/example-notebook.structuredefinition.json diff --git a/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap b/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap index a7c62b6bf..f04d18fa6 100644 --- a/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap +++ b/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap @@ -28,6 +28,172 @@ export interface ExampleNotebook extends DomainResource { " `; +exports[`Local Package Folder - Multi-Package Generation TypeScript Generation with type-discriminated profile should generate ExampleTypedBundle profile with type-discriminated slices 1`] = ` +"// WARNING: This file is autogenerated by @atomic-ehr/codegen. +// GitHub: https://github.com/atomic-ehr/codegen +// Any manual changes made to this file may be overwritten. + +import type { Bundle, BundleEntry } from "../../hl7-fhir-r4-core/Bundle"; + +export type ExampleTypedBundle_Entry_PatientEntrySliceFlat = BundleEntry; +export type ExampleTypedBundle_Entry_OrganizationEntrySliceFlat = BundleEntry; + +import { + buildResource, + ensureProfile, + applySliceMatch, + matchesValue, + setArraySlice, + getArraySlice, + ensureSliceDefaults, + validateRequired, + validateExcluded, + validateFixedValue, + validateSliceCardinality, + validateEnum, + validateReference, + validateChoiceRequired, + validateMustSupport, +} from "../../profile-helpers"; + +export type ExampleTypedBundleProfileRaw = { + type: ("document" | "message" | "transaction" | "transaction-response" | "batch" | "batch-response" | "history" | "searchset" | "collection"); + entry?: BundleEntry[]; +} + +// CanonicalURL: http://example.org/fhir/StructureDefinition/ExampleTypedBundle (pkg: example.folder.structures#0.0.1) +export class ExampleTypedBundleProfile { + static readonly canonicalUrl = "http://example.org/fhir/StructureDefinition/ExampleTypedBundle"; + + private static readonly PatientEntrySliceMatch: Record = {"resource":{"resourceType":"Patient"}}; + private static readonly OrganizationEntrySliceMatch: Record = {"resource":{"resourceType":"Organization"}}; + + private resource: Bundle; + + constructor (resource: Bundle) { + this.resource = resource; + } + + static from (resource: Bundle) : ExampleTypedBundleProfile { + if (!resource.meta?.profile?.includes(ExampleTypedBundleProfile.canonicalUrl)) { + throw new Error(\`ExampleTypedBundleProfile: meta.profile must include \${ExampleTypedBundleProfile.canonicalUrl}\`) + } + const profile = new ExampleTypedBundleProfile(resource); + const { errors } = profile.validate(); + if (errors.length > 0) throw new Error(errors.join("; ")) + return profile; + } + + static apply (resource: Bundle) : ExampleTypedBundleProfile { + ensureProfile(resource, ExampleTypedBundleProfile.canonicalUrl); + return new ExampleTypedBundleProfile(resource); + } + + static createResource (args: ExampleTypedBundleProfileRaw) : Bundle { + const entryWithDefaults = ensureSliceDefaults( + [...(args.entry ?? [])], + ExampleTypedBundleProfile.PatientEntrySliceMatch, + ); + + const resource = buildResource( { + resourceType: "Bundle", + entry: entryWithDefaults, + type: args.type, + meta: { profile: [ExampleTypedBundleProfile.canonicalUrl] }, + }) + return resource; + } + + static create (args: ExampleTypedBundleProfileRaw) : ExampleTypedBundleProfile { + return ExampleTypedBundleProfile.apply(ExampleTypedBundleProfile.createResource(args)); + } + + toResource () : Bundle { + return this.resource; + } + + // Field accessors + getType () : ("document" | "message" | "transaction" | "transaction-response" | "batch" | "batch-response" | "history" | "searchset" | "collection") | undefined { + return this.resource.type as ("document" | "message" | "transaction" | "transaction-response" | "batch" | "batch-response" | "history" | "searchset" | "collection") | undefined; + } + + setType (value: ("document" | "message" | "transaction" | "transaction-response" | "batch" | "batch-response" | "history" | "searchset" | "collection")) : this { + Object.assign(this.resource, { type: value }); + return this; + } + + getEntry () : BundleEntry[] | undefined { + return this.resource.entry as BundleEntry[] | undefined; + } + + setEntry (value: BundleEntry[]) : this { + Object.assign(this.resource, { entry: value }); + return this; + } + + // Extensions + // Slices + public setPatientEntry (input?: ExampleTypedBundle_Entry_PatientEntrySliceFlat | BundleEntry): this { + const match = ExampleTypedBundleProfile.PatientEntrySliceMatch + if (input && matchesValue(input, match)) { + setArraySlice(this.resource.entry ??= [], match, input as BundleEntry) + return this + } + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.entry ??= [], match, value) + return this + } + + public setOrganizationEntry (input?: ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | BundleEntry): this { + const match = ExampleTypedBundleProfile.OrganizationEntrySliceMatch + if (input && matchesValue(input, match)) { + setArraySlice(this.resource.entry ??= [], match, input as BundleEntry) + return this + } + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.entry ??= [], match, value) + return this + } + + public getPatientEntry(mode: 'flat'): ExampleTypedBundle_Entry_PatientEntrySliceFlat | undefined; + public getPatientEntry(mode: 'raw'): BundleEntry | undefined; + public getPatientEntry(): ExampleTypedBundle_Entry_PatientEntrySliceFlat | undefined; + public getPatientEntry (mode: 'flat' | 'raw' = 'flat'): ExampleTypedBundle_Entry_PatientEntrySliceFlat | BundleEntry | undefined { + const match = ExampleTypedBundleProfile.PatientEntrySliceMatch + const item = getArraySlice(this.resource.entry, match) + if (!item) return undefined + if (mode === 'raw') return item + return item as ExampleTypedBundle_Entry_PatientEntrySliceFlat + } + + public getOrganizationEntry(mode: 'flat'): ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | undefined; + public getOrganizationEntry(mode: 'raw'): BundleEntry | undefined; + public getOrganizationEntry(): ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | undefined; + public getOrganizationEntry (mode: 'flat' | 'raw' = 'flat'): ExampleTypedBundle_Entry_OrganizationEntrySliceFlat | BundleEntry | undefined { + const match = ExampleTypedBundleProfile.OrganizationEntrySliceMatch + const item = getArraySlice(this.resource.entry, match) + if (!item) return undefined + if (mode === 'raw') return item + return item as ExampleTypedBundle_Entry_OrganizationEntrySliceFlat + } + + // Validation + validate(): { errors: string[]; warnings: string[] } { + const profileName = "ExampleTypedBundle" + const res = this.resource + return { + errors: [ + ...validateSliceCardinality(res, profileName, "entry", {"resource":{"resourceType":"Patient"}}, "PatientEntry", 1, 1), + ], + warnings: [], + } + } + +} + +" +`; + exports[`Local Package Folder - Multi-Package Generation Python Generation should generate ExampleNotebook type (promoted logical) 1`] = ` "# WARNING: This file is autogenerated by @atomic-ehr/codegen. # GitHub: https://github.com/atomic-ehr/codegen diff --git a/test/api/write-generator/multi-package/local-package.test.ts b/test/api/write-generator/multi-package/local-package.test.ts index 24e168245..238e871fb 100644 --- a/test/api/write-generator/multi-package/local-package.test.ts +++ b/test/api/write-generator/multi-package/local-package.test.ts @@ -4,7 +4,7 @@ import { APIBuilder } from "@root/api/builder"; import type { CanonicalUrl } from "@root/typeschema/types"; import { mkSilentLogger } from "@typeschema-test/utils"; -const LOCAL_PACKAGE_PATH = Path.join(__dirname, "../../../assets/local-package/structure-definitions"); +const LOCAL_PACKAGE_PATH = Path.join(__dirname, "../../../../examples/local-package-folder/structure-definitions"); /** * Tests for local package folder functionality with multi-package dependency resolution. @@ -60,6 +60,33 @@ describe("Local Package Folder - Multi-Package Generation", async () => { }); }); + describe("TypeScript Generation with type-discriminated profile", async () => { + const result = await new APIBuilder({ logger: mkSilentLogger() }) + .localStructureDefinitions(localPackageConfig) + .typeSchema({ + treeShake: { + "example.folder.structures": { + "http://example.org/fhir/StructureDefinition/ExampleTypedBundle": {}, + }, + }, + }) + .typescript({ inMemoryOnly: true, generateProfile: true, withDebugComment: false }) + .generate(); + + it("should succeed", () => { + expect(result.success).toBeTrue(); + }); + + it("should generate ExampleTypedBundle profile with type-discriminated slices", () => { + const profileFile = + result.filesGenerated[ + "generated/types/example-folder-structures/profiles/Bundle_ExampleTypedBundle.ts" + ]; + expect(profileFile).toBeDefined(); + expect(profileFile).toMatchSnapshot(); + }); + }); + describe("Python Generation", async () => { const result = await new APIBuilder({ logger: mkSilentLogger() }) .localStructureDefinitions(localPackageConfig) diff --git a/test/assets/local-package/structure-definitions/example-notebook.structuredefinition.json b/test/assets/local-package/structure-definitions/example-notebook.structuredefinition.json deleted file mode 100644 index 6b39bc4ea..000000000 --- a/test/assets/local-package/structure-definitions/example-notebook.structuredefinition.json +++ /dev/null @@ -1,175 +0,0 @@ -{ - "resourceType": "StructureDefinition", - "id": "example-notebook", - "url": "http://example.org/fhir/StructureDefinition/ExampleNotebook", - "version": "0.0.1", - "name": "ExampleNotebook", - "title": "Example Notebook Logical Model", - "status": "draft", - "date": "2024-01-01", - "publisher": "Atomic Example Studio", - "description": "Logical resource showcasing a simple notebook record with fields used in examples.", - "fhirVersion": "4.0.1", - "kind": "logical", - "abstract": false, - "type": "ExampleNotebook", - "baseDefinition": "http://hl7.org/fhir/StructureDefinition/DomainResource", - "derivation": "specialization", - "snapshot": { - "element": [ - { - "id": "ExampleNotebook", - "path": "ExampleNotebook", - "short": "Example logical notebook resource", - "definition": "A lightweight notebook entry capturing author, title, and content.", - "min": 0, - "max": "*", - "type": [ - { - "code": "DomainResource" - } - ] - }, - { - "id": "ExampleNotebook.identifier", - "path": "ExampleNotebook.identifier", - "min": 1, - "max": "1", - "short": "Unique identifier for the notebook entry", - "type": [ - { - "code": "Identifier" - } - ] - }, - { - "id": "ExampleNotebook.title", - "path": "ExampleNotebook.title", - "min": 1, - "max": "1", - "short": "Human readable title", - "type": [ - { - "code": "string" - } - ] - }, - { - "id": "ExampleNotebook.author", - "path": "ExampleNotebook.author", - "min": 1, - "max": "1", - "short": "Reference to the author of the notebook entry", - "type": [ - { - "code": "Reference", - "targetProfile": [ - "http://hl7.org/fhir/StructureDefinition/Practitioner", - "http://hl7.org/fhir/StructureDefinition/Patient" - ] - } - ] - }, - { - "id": "ExampleNotebook.content", - "path": "ExampleNotebook.content", - "min": 1, - "max": "1", - "short": "Markdown content of the notebook entry", - "type": [ - { - "code": "markdown" - } - ] - }, - { - "id": "ExampleNotebook.tag", - "path": "ExampleNotebook.tag", - "min": 0, - "max": "*", - "short": "User defined tags", - "type": [ - { - "code": "Coding" - } - ] - } - ] - }, - "differential": { - "element": [ - { - "id": "ExampleNotebook", - "path": "ExampleNotebook", - "short": "Example logical notebook resource", - "definition": "A lightweight notebook entry capturing author, title, and content.", - "min": 0, - "max": "*" - }, - { - "id": "ExampleNotebook.identifier", - "path": "ExampleNotebook.identifier", - "min": 1, - "max": "1", - "short": "Unique identifier for the notebook entry", - "type": [ - { - "code": "Identifier" - } - ] - }, - { - "id": "ExampleNotebook.title", - "path": "ExampleNotebook.title", - "min": 1, - "max": "1", - "short": "Human readable title", - "type": [ - { - "code": "string" - } - ] - }, - { - "id": "ExampleNotebook.author", - "path": "ExampleNotebook.author", - "min": 1, - "max": "1", - "short": "Reference to the author", - "type": [ - { - "code": "Reference", - "targetProfile": [ - "http://hl7.org/fhir/StructureDefinition/Practitioner", - "http://hl7.org/fhir/StructureDefinition/Patient" - ] - } - ] - }, - { - "id": "ExampleNotebook.content", - "path": "ExampleNotebook.content", - "min": 1, - "max": "1", - "short": "Markdown content of the notebook entry", - "type": [ - { - "code": "markdown" - } - ] - }, - { - "id": "ExampleNotebook.tag", - "path": "ExampleNotebook.tag", - "min": 0, - "max": "*", - "short": "User defined tags", - "type": [ - { - "code": "Coding" - } - ] - } - ] - } -} \ No newline at end of file From af9c77257096a60356b5c7c53241bb5f99853328 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 18 Mar 2026 12:01:01 +0100 Subject: [PATCH 4/7] Add ExampleTypedBundle demo and wire into make all Add SD, demo test, and tsconfig for local-package-folder example. Add test-local-package-folder-example make target with typecheck. --- Makefile | 9 +- examples/local-package-folder/generate.ts | 6 +- .../profile-typed-bundle.test.ts | 92 +++++++++++++++++++ ...mple-typed-bundle.structuredefinition.json | 71 ++++++++++++++ examples/local-package-folder/tsconfig.json | 4 + 5 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 examples/local-package-folder/profile-typed-bundle.test.ts create mode 100644 examples/local-package-folder/structure-definitions/example-typed-bundle.structuredefinition.json create mode 100644 examples/local-package-folder/tsconfig.json diff --git a/Makefile b/Makefile index 912e67804..52744744b 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,9 @@ LINT = bunx biome check --write TEST = bun test VERSION = $(shell cat package.json | grep version | sed -E 's/ *"version": "//' | sed -E 's/",.*//') -.PHONY: all typecheck test-typeschema test-register test-codegen test-typescript-r4-example +.PHONY: all typecheck test-typeschema test-register test-codegen test-typescript-r4-example test-local-package-folder-example -all: test-codegen test-typescript-r4-example test-typescript-us-core-example test-typescript-ccda-example test-typescript-sql-on-fhir-example lint-unsafe test-all-example-generation +all: test-codegen test-typescript-r4-example test-typescript-us-core-example test-typescript-ccda-example test-typescript-sql-on-fhir-example test-local-package-folder-example lint-unsafe test-all-example-generation generate-types: bun run scripts/generate-types.ts @@ -84,6 +84,11 @@ test-typescript-ccda-example: typecheck ./examples/typescript-ccda/demo-cda.test.ts \ ./examples/typescript-ccda/demo-ccda.test.ts +test-local-package-folder-example: typecheck + bun run examples/local-package-folder/generate.ts + $(TYPECHECK) --project examples/local-package-folder/tsconfig.json + $(TEST) ./examples/local-package-folder/ + test-mustache-java-r4-example: typecheck format lint bun run examples/mustache/mustache-java-r4-gen.ts $(TYPECHECK) --project examples/mustache/tsconfig.examples-mustache.json diff --git a/examples/local-package-folder/generate.ts b/examples/local-package-folder/generate.ts index 7b1ca4212..54fb6d83a 100644 --- a/examples/local-package-folder/generate.ts +++ b/examples/local-package-folder/generate.ts @@ -5,9 +5,7 @@ import { APIBuilder, prettyReport } from "../../src/api"; const __dirname = Path.dirname(fileURLToPath(import.meta.url)); async function generateFromLocalPackageFolder() { - const builder = new APIBuilder({ - logLevel: "INFO", - }); + const builder = new APIBuilder(); const report = await builder .localStructureDefinitions({ @@ -21,9 +19,11 @@ async function generateFromLocalPackageFolder() { treeShake: { "example.folder.structures": { "http://example.org/fhir/StructureDefinition/ExampleNotebook": {}, + "http://example.org/fhir/StructureDefinition/ExampleTypedBundle": {}, }, }, }) + .introspection({typeSchemas: "ts/"}) .outputTo("./examples/local-package-folder/fhir-types") .generate(); diff --git a/examples/local-package-folder/profile-typed-bundle.test.ts b/examples/local-package-folder/profile-typed-bundle.test.ts new file mode 100644 index 000000000..60af52b79 --- /dev/null +++ b/examples/local-package-folder/profile-typed-bundle.test.ts @@ -0,0 +1,92 @@ +/** + * Type discriminator slicing demo — ExampleTypedBundle profile. + * + * The profile slices Bundle.entry[] by resource type: + * - PatientEntry (min: 1, max: 1) — entry where resource is Patient + * - OrganizationEntry (min: 0, max: *) — entry where resource is Organization + */ + +import { describe, expect, test } from "bun:test"; +import { ExampleTypedBundleProfile } from "./fhir-types/example-folder-structures/profiles/Bundle_ExampleTypedBundle"; + +const createBundle = () => ExampleTypedBundleProfile.create({ type: "collection" }); + +describe("type-discriminated bundle slices", () => { + test("create() auto-populates a PatientEntry stub (min: 1)", () => { + const bundle = createBundle(); + const entry = bundle.toResource().entry; + expect(entry).toHaveLength(1); + expect(entry![0]!.resource as unknown).toEqual({ resourceType: "Patient" }); + }); + + test("setPatientEntry inserts a typed patient entry", () => { + const bundle = createBundle(); + bundle.setPatientEntry({ + resource: { resourceType: "Patient", name: [{ family: "Smith" }] }, + } as never); + + const raw = bundle.getPatientEntry("raw")!; + expect(raw.resource!.resourceType as string).toBe("Patient"); + expect((raw.resource as unknown as Record).name).toEqual([{ family: "Smith" }]); + }); + + test("setPatientEntry replaces existing patient entry (no duplicates)", () => { + const bundle = createBundle(); + bundle.setPatientEntry({ + resource: { resourceType: "Patient", name: [{ family: "Smith" }] }, + } as never); + bundle.setPatientEntry({ + resource: { resourceType: "Patient", name: [{ family: "Jones" }] }, + } as never); + + const patients = bundle.toResource().entry!.filter((e) => (e.resource?.resourceType as string) === "Patient"); + expect(patients).toHaveLength(1); + expect((patients[0]!.resource as unknown as Record).name).toEqual([{ family: "Jones" }]); + }); + + test("setOrganizationEntry adds an organization entry", () => { + const bundle = createBundle(); + bundle.setOrganizationEntry({ + resource: { resourceType: "Organization", name: "Acme Corp" }, + } as never); + + const raw = bundle.getOrganizationEntry("raw")!; + expect(raw.resource!.resourceType as string).toBe("Organization"); + expect((raw.resource as unknown as Record).name).toBe("Acme Corp"); + }); + + test("getPatientEntry('flat') returns the entry as-is (no keys stripped)", () => { + const bundle = createBundle(); + bundle.setPatientEntry({ + fullUrl: "urn:uuid:patient-1", + resource: { resourceType: "Patient", active: true }, + } as never); + + const flat = bundle.getPatientEntry("flat")!; + expect(flat.fullUrl).toBe("urn:uuid:patient-1"); + expect(flat.resource!.resourceType as string).toBe("Patient"); + }); + + test("validate() checks PatientEntry cardinality", () => { + const bundle = ExampleTypedBundleProfile.apply({ + resourceType: "Bundle", + type: "collection", + }); + const { errors } = bundle.validate(); + expect(errors).toEqual(["ExampleTypedBundle.entry: slice 'PatientEntry' requires at least 1 item(s), found 0"]); + }); + + test("fluent chaining across slice setters", () => { + const bundle = createBundle() + .setPatientEntry({ + resource: { resourceType: "Patient", active: true }, + } as never) + .setOrganizationEntry({ + resource: { resourceType: "Organization", name: "Clinic" }, + } as never); + + expect(bundle.getPatientEntry("raw")!.resource!.resourceType as string).toBe("Patient"); + expect(bundle.getOrganizationEntry("raw")!.resource!.resourceType as string).toBe("Organization"); + expect(bundle.toResource().entry).toHaveLength(2); + }); +}); diff --git a/examples/local-package-folder/structure-definitions/example-typed-bundle.structuredefinition.json b/examples/local-package-folder/structure-definitions/example-typed-bundle.structuredefinition.json new file mode 100644 index 000000000..aeae725b5 --- /dev/null +++ b/examples/local-package-folder/structure-definitions/example-typed-bundle.structuredefinition.json @@ -0,0 +1,71 @@ +{ + "resourceType": "StructureDefinition", + "id": "example-typed-bundle", + "url": "http://example.org/fhir/StructureDefinition/ExampleTypedBundle", + "version": "0.0.1", + "name": "ExampleTypedBundle", + "title": "Example Typed Bundle", + "status": "draft", + "date": "2024-01-01", + "publisher": "Atomic Example Studio", + "description": "A Bundle profile that slices entry[] by resource type — used to test type discriminator support.", + "fhirVersion": "4.0.1", + "kind": "resource", + "abstract": false, + "type": "Bundle", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Bundle", + "derivation": "constraint", + "differential": { + "element": [ + { + "id": "Bundle.entry", + "path": "Bundle.entry", + "slicing": { + "discriminator": [ + { + "type": "type", + "path": "resource" + } + ], + "rules": "open", + "ordered": false + } + }, + { + "id": "Bundle.entry:PatientEntry", + "path": "Bundle.entry", + "sliceName": "PatientEntry", + "min": 1, + "max": "1", + "mustSupport": true + }, + { + "id": "Bundle.entry:PatientEntry.resource", + "path": "Bundle.entry.resource", + "min": 1, + "type": [ + { + "code": "Patient" + } + ] + }, + { + "id": "Bundle.entry:OrganizationEntry", + "path": "Bundle.entry", + "sliceName": "OrganizationEntry", + "min": 0, + "max": "*" + }, + { + "id": "Bundle.entry:OrganizationEntry.resource", + "path": "Bundle.entry.resource", + "min": 1, + "type": [ + { + "code": "Organization" + } + ] + } + ] + } +} diff --git a/examples/local-package-folder/tsconfig.json b/examples/local-package-folder/tsconfig.json new file mode 100644 index 000000000..bb542f383 --- /dev/null +++ b/examples/local-package-folder/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["."] +} From 5cfa6c856fd7141728a5102a2617118f28d5493a Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 18 Mar 2026 13:10:56 +0100 Subject: [PATCH 5/7] Improve typed bundle test with const resources and BundleEntry get/set coverage --- .../profile-typed-bundle.test.ts | 83 +++++++++++-------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/examples/local-package-folder/profile-typed-bundle.test.ts b/examples/local-package-folder/profile-typed-bundle.test.ts index 60af52b79..74c6352c1 100644 --- a/examples/local-package-folder/profile-typed-bundle.test.ts +++ b/examples/local-package-folder/profile-typed-bundle.test.ts @@ -11,6 +11,12 @@ import { ExampleTypedBundleProfile } from "./fhir-types/example-folder-structure const createBundle = () => ExampleTypedBundleProfile.create({ type: "collection" }); +const smithPatient = { resourceType: "Patient", name: [{ family: "Smith" }] } as const; +const jonesPatient = { resourceType: "Patient", name: [{ family: "Jones" }] } as const; +const activePatient = { resourceType: "Patient", active: true } as const; +const acmeOrg = { resourceType: "Organization", name: "Acme Corp" } as const; +const clinicOrg = { resourceType: "Organization", name: "Clinic" } as const; + describe("type-discriminated bundle slices", () => { test("create() auto-populates a PatientEntry stub (min: 1)", () => { const bundle = createBundle(); @@ -21,50 +27,35 @@ describe("type-discriminated bundle slices", () => { test("setPatientEntry inserts a typed patient entry", () => { const bundle = createBundle(); - bundle.setPatientEntry({ - resource: { resourceType: "Patient", name: [{ family: "Smith" }] }, - } as never); + bundle.setPatientEntry({ resource: smithPatient } as never); - const raw = bundle.getPatientEntry("raw")!; - expect(raw.resource!.resourceType as string).toBe("Patient"); - expect((raw.resource as unknown as Record).name).toEqual([{ family: "Smith" }]); + expect(bundle.getPatientEntry()!.resource as unknown).toEqual(smithPatient); }); test("setPatientEntry replaces existing patient entry (no duplicates)", () => { const bundle = createBundle(); - bundle.setPatientEntry({ - resource: { resourceType: "Patient", name: [{ family: "Smith" }] }, - } as never); - bundle.setPatientEntry({ - resource: { resourceType: "Patient", name: [{ family: "Jones" }] }, - } as never); - - const patients = bundle.toResource().entry!.filter((e) => (e.resource?.resourceType as string) === "Patient"); + bundle.setPatientEntry({ resource: smithPatient } as never); + bundle.setPatientEntry({ resource: jonesPatient } as never); + + const patients = bundle.toResource().entry!.filter((e) => (e.resource as { resourceType: string })?.resourceType === "Patient"); expect(patients).toHaveLength(1); - expect((patients[0]!.resource as unknown as Record).name).toEqual([{ family: "Jones" }]); + expect(bundle.getPatientEntry()!.resource as unknown).toEqual(jonesPatient); }); test("setOrganizationEntry adds an organization entry", () => { const bundle = createBundle(); - bundle.setOrganizationEntry({ - resource: { resourceType: "Organization", name: "Acme Corp" }, - } as never); + bundle.setOrganizationEntry({ resource: acmeOrg } as never); - const raw = bundle.getOrganizationEntry("raw")!; - expect(raw.resource!.resourceType as string).toBe("Organization"); - expect((raw.resource as unknown as Record).name).toBe("Acme Corp"); + expect(bundle.getOrganizationEntry()!.resource as unknown).toEqual(acmeOrg); }); test("getPatientEntry('flat') returns the entry as-is (no keys stripped)", () => { const bundle = createBundle(); - bundle.setPatientEntry({ - fullUrl: "urn:uuid:patient-1", - resource: { resourceType: "Patient", active: true }, - } as never); + bundle.setPatientEntry({ fullUrl: "urn:uuid:patient-1", resource: activePatient } as never); const flat = bundle.getPatientEntry("flat")!; expect(flat.fullUrl).toBe("urn:uuid:patient-1"); - expect(flat.resource!.resourceType as string).toBe("Patient"); + expect(flat.resource as unknown).toEqual(activePatient); }); test("validate() checks PatientEntry cardinality", () => { @@ -78,15 +69,37 @@ describe("type-discriminated bundle slices", () => { test("fluent chaining across slice setters", () => { const bundle = createBundle() - .setPatientEntry({ - resource: { resourceType: "Patient", active: true }, - } as never) - .setOrganizationEntry({ - resource: { resourceType: "Organization", name: "Clinic" }, - } as never); - - expect(bundle.getPatientEntry("raw")!.resource!.resourceType as string).toBe("Patient"); - expect(bundle.getOrganizationEntry("raw")!.resource!.resourceType as string).toBe("Organization"); + .setPatientEntry({ resource: activePatient } as never) + .setOrganizationEntry({ resource: clinicOrg } as never); + + expect(bundle.getPatientEntry()!.resource as unknown).toEqual(activePatient); + expect(bundle.getOrganizationEntry()!.resource as unknown).toEqual(clinicOrg); expect(bundle.toResource().entry).toHaveLength(2); }); + + test("set/get PatientEntry with full BundleEntry input", () => { + const bundle = createBundle(); + bundle.setPatientEntry({ fullUrl: "urn:uuid:p1", resource: smithPatient } as never); + + const raw = bundle.getPatientEntry("raw")!; + expect(raw.fullUrl).toBe("urn:uuid:p1"); + expect(raw.resource as unknown).toEqual(smithPatient); + + const flat = bundle.getPatientEntry("flat")!; + expect(flat.fullUrl).toBe("urn:uuid:p1"); + expect(flat.resource as unknown).toEqual(smithPatient); + }); + + test("set/get OrganizationEntry with full BundleEntry input", () => { + const bundle = createBundle(); + bundle.setOrganizationEntry({ fullUrl: "urn:uuid:o1", resource: acmeOrg } as never); + + const raw = bundle.getOrganizationEntry("raw")!; + expect(raw.fullUrl).toBe("urn:uuid:o1"); + expect(raw.resource as unknown).toEqual(acmeOrg); + + const flat = bundle.getOrganizationEntry("flat")!; + expect(flat.fullUrl).toBe("urn:uuid:o1"); + expect(flat.resource as unknown).toEqual(acmeOrg); + }); }); From 971035c9097fad59ebc169a54049c960c9f38aa4 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 18 Mar 2026 13:18:38 +0100 Subject: [PATCH 6/7] Add Patient and Organization to tree shake and use FHIR core types in test --- examples/local-package-folder/generate.ts | 4 ++ .../profile-typed-bundle.test.ts | 55 ++++++++++--------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/examples/local-package-folder/generate.ts b/examples/local-package-folder/generate.ts index 54fb6d83a..3565303d0 100644 --- a/examples/local-package-folder/generate.ts +++ b/examples/local-package-folder/generate.ts @@ -21,6 +21,10 @@ async function generateFromLocalPackageFolder() { "http://example.org/fhir/StructureDefinition/ExampleNotebook": {}, "http://example.org/fhir/StructureDefinition/ExampleTypedBundle": {}, }, + "hl7.fhir.r4.core": { + "http://hl7.org/fhir/StructureDefinition/Patient": {}, + "http://hl7.org/fhir/StructureDefinition/Organization": {}, + }, }, }) .introspection({typeSchemas: "ts/"}) diff --git a/examples/local-package-folder/profile-typed-bundle.test.ts b/examples/local-package-folder/profile-typed-bundle.test.ts index 74c6352c1..fd5cdf374 100644 --- a/examples/local-package-folder/profile-typed-bundle.test.ts +++ b/examples/local-package-folder/profile-typed-bundle.test.ts @@ -7,55 +7,58 @@ */ import { describe, expect, test } from "bun:test"; +import type { Organization } from "./fhir-types/hl7-fhir-r4-core/Organization"; +import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient"; import { ExampleTypedBundleProfile } from "./fhir-types/example-folder-structures/profiles/Bundle_ExampleTypedBundle"; const createBundle = () => ExampleTypedBundleProfile.create({ type: "collection" }); -const smithPatient = { resourceType: "Patient", name: [{ family: "Smith" }] } as const; -const jonesPatient = { resourceType: "Patient", name: [{ family: "Jones" }] } as const; -const activePatient = { resourceType: "Patient", active: true } as const; -const acmeOrg = { resourceType: "Organization", name: "Acme Corp" } as const; -const clinicOrg = { resourceType: "Organization", name: "Clinic" } as const; +const smithPatient: Patient = { resourceType: "Patient", name: [{ family: "Smith" }] }; +const jonesPatient: Patient = { resourceType: "Patient", name: [{ family: "Jones" }] }; +const activePatient: Patient = { resourceType: "Patient", active: true }; +const acmeOrg: Organization = { resourceType: "Organization", name: "Acme Corp" }; +const clinicOrg: Organization = { resourceType: "Organization", name: "Clinic" }; describe("type-discriminated bundle slices", () => { test("create() auto-populates a PatientEntry stub (min: 1)", () => { const bundle = createBundle(); const entry = bundle.toResource().entry; expect(entry).toHaveLength(1); - expect(entry![0]!.resource as unknown).toEqual({ resourceType: "Patient" }); + expect(entry![0]!.resource).toEqual({ resourceType: "Patient" }); }); test("setPatientEntry inserts a typed patient entry", () => { const bundle = createBundle(); - bundle.setPatientEntry({ resource: smithPatient } as never); + bundle.setPatientEntry({ resource: smithPatient }); - expect(bundle.getPatientEntry()!.resource as unknown).toEqual(smithPatient); + const entry = bundle.getPatientEntry()!; + expect(entry.resource).toEqual(smithPatient); }); test("setPatientEntry replaces existing patient entry (no duplicates)", () => { const bundle = createBundle(); - bundle.setPatientEntry({ resource: smithPatient } as never); - bundle.setPatientEntry({ resource: jonesPatient } as never); + bundle.setPatientEntry({ resource: smithPatient }); + bundle.setPatientEntry({ resource: jonesPatient }); - const patients = bundle.toResource().entry!.filter((e) => (e.resource as { resourceType: string })?.resourceType === "Patient"); + const patients = bundle.toResource().entry!.filter((e) => e.resource?.resourceType === "Patient"); expect(patients).toHaveLength(1); - expect(bundle.getPatientEntry()!.resource as unknown).toEqual(jonesPatient); + expect(bundle.getPatientEntry()!.resource).toEqual(jonesPatient); }); test("setOrganizationEntry adds an organization entry", () => { const bundle = createBundle(); - bundle.setOrganizationEntry({ resource: acmeOrg } as never); + bundle.setOrganizationEntry({ resource: acmeOrg }); - expect(bundle.getOrganizationEntry()!.resource as unknown).toEqual(acmeOrg); + expect(bundle.getOrganizationEntry()!.resource).toEqual(acmeOrg); }); test("getPatientEntry('flat') returns the entry as-is (no keys stripped)", () => { const bundle = createBundle(); - bundle.setPatientEntry({ fullUrl: "urn:uuid:patient-1", resource: activePatient } as never); + bundle.setPatientEntry({ fullUrl: "urn:uuid:patient-1", resource: activePatient }); const flat = bundle.getPatientEntry("flat")!; expect(flat.fullUrl).toBe("urn:uuid:patient-1"); - expect(flat.resource as unknown).toEqual(activePatient); + expect(flat.resource).toEqual(activePatient); }); test("validate() checks PatientEntry cardinality", () => { @@ -69,37 +72,37 @@ describe("type-discriminated bundle slices", () => { test("fluent chaining across slice setters", () => { const bundle = createBundle() - .setPatientEntry({ resource: activePatient } as never) - .setOrganizationEntry({ resource: clinicOrg } as never); + .setPatientEntry({ resource: activePatient }) + .setOrganizationEntry({ resource: clinicOrg }); - expect(bundle.getPatientEntry()!.resource as unknown).toEqual(activePatient); - expect(bundle.getOrganizationEntry()!.resource as unknown).toEqual(clinicOrg); + expect(bundle.getPatientEntry()!.resource).toEqual(activePatient); + expect(bundle.getOrganizationEntry()!.resource).toEqual(clinicOrg); expect(bundle.toResource().entry).toHaveLength(2); }); test("set/get PatientEntry with full BundleEntry input", () => { const bundle = createBundle(); - bundle.setPatientEntry({ fullUrl: "urn:uuid:p1", resource: smithPatient } as never); + bundle.setPatientEntry({ fullUrl: "urn:uuid:p1", resource: smithPatient }); const raw = bundle.getPatientEntry("raw")!; expect(raw.fullUrl).toBe("urn:uuid:p1"); - expect(raw.resource as unknown).toEqual(smithPatient); + expect(raw.resource).toEqual(smithPatient); const flat = bundle.getPatientEntry("flat")!; expect(flat.fullUrl).toBe("urn:uuid:p1"); - expect(flat.resource as unknown).toEqual(smithPatient); + expect(flat.resource).toEqual(smithPatient); }); test("set/get OrganizationEntry with full BundleEntry input", () => { const bundle = createBundle(); - bundle.setOrganizationEntry({ fullUrl: "urn:uuid:o1", resource: acmeOrg } as never); + bundle.setOrganizationEntry({ fullUrl: "urn:uuid:o1", resource: acmeOrg }); const raw = bundle.getOrganizationEntry("raw")!; expect(raw.fullUrl).toBe("urn:uuid:o1"); - expect(raw.resource as unknown).toEqual(acmeOrg); + expect(raw.resource).toEqual(acmeOrg); const flat = bundle.getOrganizationEntry("flat")!; expect(flat.fullUrl).toBe("urn:uuid:o1"); - expect(flat.resource as unknown).toEqual(acmeOrg); + expect(flat.resource).toEqual(acmeOrg); }); }); From 7c47c6843568894e55a76845ab2bebd3b5898e7b Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 18 Mar 2026 13:37:08 +0100 Subject: [PATCH 7/7] Fix lint formatting in local-package-folder example --- examples/local-package-folder/generate.ts | 2 +- examples/local-package-folder/profile-typed-bundle.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/local-package-folder/generate.ts b/examples/local-package-folder/generate.ts index 3565303d0..f6b189ce1 100644 --- a/examples/local-package-folder/generate.ts +++ b/examples/local-package-folder/generate.ts @@ -27,7 +27,7 @@ async function generateFromLocalPackageFolder() { }, }, }) - .introspection({typeSchemas: "ts/"}) + .introspection({ typeSchemas: "ts/" }) .outputTo("./examples/local-package-folder/fhir-types") .generate(); diff --git a/examples/local-package-folder/profile-typed-bundle.test.ts b/examples/local-package-folder/profile-typed-bundle.test.ts index fd5cdf374..247611ec1 100644 --- a/examples/local-package-folder/profile-typed-bundle.test.ts +++ b/examples/local-package-folder/profile-typed-bundle.test.ts @@ -7,9 +7,9 @@ */ import { describe, expect, test } from "bun:test"; +import { ExampleTypedBundleProfile } from "./fhir-types/example-folder-structures/profiles/Bundle_ExampleTypedBundle"; import type { Organization } from "./fhir-types/hl7-fhir-r4-core/Organization"; import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient"; -import { ExampleTypedBundleProfile } from "./fhir-types/example-folder-structures/profiles/Bundle_ExampleTypedBundle"; const createBundle = () => ExampleTypedBundleProfile.create({ type: "collection" });