Skip to content

Commit ef0656f

Browse files
authored
Merge pull request #108 from atomic-ehr/resolve-collisions
TS: Add type discriminator support for slicing
2 parents f9ba379 + 7c47c68 commit ef0656f

12 files changed

Lines changed: 433 additions & 190 deletions

File tree

Makefile

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ LINT = bunx biome check --write
66
TEST = bun test
77
VERSION = $(shell cat package.json | grep version | sed -E 's/ *"version": "//' | sed -E 's/",.*//')
88

9-
.PHONY: all typecheck test-typeschema test-register test-codegen test-typescript-r4-example
9+
.PHONY: all typecheck test-typeschema test-register test-codegen test-typescript-r4-example test-local-package-folder-example
1010

11-
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
11+
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
1212

1313
generate-types:
1414
bun run scripts/generate-types.ts
@@ -84,6 +84,11 @@ test-typescript-ccda-example: typecheck
8484
./examples/typescript-ccda/demo-cda.test.ts \
8585
./examples/typescript-ccda/demo-ccda.test.ts
8686

87+
test-local-package-folder-example: typecheck
88+
bun run examples/local-package-folder/generate.ts
89+
$(TYPECHECK) --project examples/local-package-folder/tsconfig.json
90+
$(TEST) ./examples/local-package-folder/
91+
8792
test-mustache-java-r4-example: typecheck format lint
8893
bun run examples/mustache/mustache-java-r4-gen.ts
8994
$(TYPECHECK) --project examples/mustache/tsconfig.examples-mustache.json

docs/design/slices.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ bp.validate();
118118
- No compile-time enforcement of slice constraints
119119
- No dedicated types for slice elements (see Refine below)
120120
- Array ordering is not enforced by the setter
121-
- Only `value`/`pattern` discriminator types are supported; `type`, `profile`, and `exists` discriminators are not yet implemented
121+
- Only `value`/`pattern`/`type` (resource type) discriminator types are supported; `profile` and `exists` discriminators are not yet implemented
122122

123123
## Refine (Not Implemented)
124124

@@ -154,7 +154,7 @@ class USCoreBloodPressureProfile {
154154
|---|---|---|
155155
| `value` | Supported | Fixed value matching (most common) |
156156
| `pattern` | Supported | Pattern matching on element |
157-
| `type` | Not supported | Discriminate by element type |
157+
| `type` | Partial | Resource type discrimination (by `resourceType` field) |
158158
| `profile` | Not supported | Discriminate by profile URL |
159159
| `exists` | Not supported | Discriminate by field presence |
160160

examples/local-package-folder/generate.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import { APIBuilder, prettyReport } from "../../src/api";
55
const __dirname = Path.dirname(fileURLToPath(import.meta.url));
66

77
async function generateFromLocalPackageFolder() {
8-
const builder = new APIBuilder({
9-
logLevel: "INFO",
10-
});
8+
const builder = new APIBuilder();
119

1210
const report = await builder
1311
.localStructureDefinitions({
@@ -21,9 +19,15 @@ async function generateFromLocalPackageFolder() {
2119
treeShake: {
2220
"example.folder.structures": {
2321
"http://example.org/fhir/StructureDefinition/ExampleNotebook": {},
22+
"http://example.org/fhir/StructureDefinition/ExampleTypedBundle": {},
23+
},
24+
"hl7.fhir.r4.core": {
25+
"http://hl7.org/fhir/StructureDefinition/Patient": {},
26+
"http://hl7.org/fhir/StructureDefinition/Organization": {},
2427
},
2528
},
2629
})
30+
.introspection({ typeSchemas: "ts/" })
2731
.outputTo("./examples/local-package-folder/fhir-types")
2832
.generate();
2933

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Type discriminator slicing demo — ExampleTypedBundle profile.
3+
*
4+
* The profile slices Bundle.entry[] by resource type:
5+
* - PatientEntry (min: 1, max: 1) — entry where resource is Patient
6+
* - OrganizationEntry (min: 0, max: *) — entry where resource is Organization
7+
*/
8+
9+
import { describe, expect, test } from "bun:test";
10+
import { ExampleTypedBundleProfile } from "./fhir-types/example-folder-structures/profiles/Bundle_ExampleTypedBundle";
11+
import type { Organization } from "./fhir-types/hl7-fhir-r4-core/Organization";
12+
import type { Patient } from "./fhir-types/hl7-fhir-r4-core/Patient";
13+
14+
const createBundle = () => ExampleTypedBundleProfile.create({ type: "collection" });
15+
16+
const smithPatient: Patient = { resourceType: "Patient", name: [{ family: "Smith" }] };
17+
const jonesPatient: Patient = { resourceType: "Patient", name: [{ family: "Jones" }] };
18+
const activePatient: Patient = { resourceType: "Patient", active: true };
19+
const acmeOrg: Organization = { resourceType: "Organization", name: "Acme Corp" };
20+
const clinicOrg: Organization = { resourceType: "Organization", name: "Clinic" };
21+
22+
describe("type-discriminated bundle slices", () => {
23+
test("create() auto-populates a PatientEntry stub (min: 1)", () => {
24+
const bundle = createBundle();
25+
const entry = bundle.toResource().entry;
26+
expect(entry).toHaveLength(1);
27+
expect(entry![0]!.resource).toEqual({ resourceType: "Patient" });
28+
});
29+
30+
test("setPatientEntry inserts a typed patient entry", () => {
31+
const bundle = createBundle();
32+
bundle.setPatientEntry({ resource: smithPatient });
33+
34+
const entry = bundle.getPatientEntry()!;
35+
expect(entry.resource).toEqual(smithPatient);
36+
});
37+
38+
test("setPatientEntry replaces existing patient entry (no duplicates)", () => {
39+
const bundle = createBundle();
40+
bundle.setPatientEntry({ resource: smithPatient });
41+
bundle.setPatientEntry({ resource: jonesPatient });
42+
43+
const patients = bundle.toResource().entry!.filter((e) => e.resource?.resourceType === "Patient");
44+
expect(patients).toHaveLength(1);
45+
expect(bundle.getPatientEntry()!.resource).toEqual(jonesPatient);
46+
});
47+
48+
test("setOrganizationEntry adds an organization entry", () => {
49+
const bundle = createBundle();
50+
bundle.setOrganizationEntry({ resource: acmeOrg });
51+
52+
expect(bundle.getOrganizationEntry()!.resource).toEqual(acmeOrg);
53+
});
54+
55+
test("getPatientEntry('flat') returns the entry as-is (no keys stripped)", () => {
56+
const bundle = createBundle();
57+
bundle.setPatientEntry({ fullUrl: "urn:uuid:patient-1", resource: activePatient });
58+
59+
const flat = bundle.getPatientEntry("flat")!;
60+
expect(flat.fullUrl).toBe("urn:uuid:patient-1");
61+
expect(flat.resource).toEqual(activePatient);
62+
});
63+
64+
test("validate() checks PatientEntry cardinality", () => {
65+
const bundle = ExampleTypedBundleProfile.apply({
66+
resourceType: "Bundle",
67+
type: "collection",
68+
});
69+
const { errors } = bundle.validate();
70+
expect(errors).toEqual(["ExampleTypedBundle.entry: slice 'PatientEntry' requires at least 1 item(s), found 0"]);
71+
});
72+
73+
test("fluent chaining across slice setters", () => {
74+
const bundle = createBundle()
75+
.setPatientEntry({ resource: activePatient })
76+
.setOrganizationEntry({ resource: clinicOrg });
77+
78+
expect(bundle.getPatientEntry()!.resource).toEqual(activePatient);
79+
expect(bundle.getOrganizationEntry()!.resource).toEqual(clinicOrg);
80+
expect(bundle.toResource().entry).toHaveLength(2);
81+
});
82+
83+
test("set/get PatientEntry with full BundleEntry input", () => {
84+
const bundle = createBundle();
85+
bundle.setPatientEntry({ fullUrl: "urn:uuid:p1", resource: smithPatient });
86+
87+
const raw = bundle.getPatientEntry("raw")!;
88+
expect(raw.fullUrl).toBe("urn:uuid:p1");
89+
expect(raw.resource).toEqual(smithPatient);
90+
91+
const flat = bundle.getPatientEntry("flat")!;
92+
expect(flat.fullUrl).toBe("urn:uuid:p1");
93+
expect(flat.resource).toEqual(smithPatient);
94+
});
95+
96+
test("set/get OrganizationEntry with full BundleEntry input", () => {
97+
const bundle = createBundle();
98+
bundle.setOrganizationEntry({ fullUrl: "urn:uuid:o1", resource: acmeOrg });
99+
100+
const raw = bundle.getOrganizationEntry("raw")!;
101+
expect(raw.fullUrl).toBe("urn:uuid:o1");
102+
expect(raw.resource).toEqual(acmeOrg);
103+
104+
const flat = bundle.getOrganizationEntry("flat")!;
105+
expect(flat.fullUrl).toBe("urn:uuid:o1");
106+
expect(flat.resource).toEqual(acmeOrg);
107+
});
108+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"resourceType": "StructureDefinition",
3+
"id": "example-typed-bundle",
4+
"url": "http://example.org/fhir/StructureDefinition/ExampleTypedBundle",
5+
"version": "0.0.1",
6+
"name": "ExampleTypedBundle",
7+
"title": "Example Typed Bundle",
8+
"status": "draft",
9+
"date": "2024-01-01",
10+
"publisher": "Atomic Example Studio",
11+
"description": "A Bundle profile that slices entry[] by resource type — used to test type discriminator support.",
12+
"fhirVersion": "4.0.1",
13+
"kind": "resource",
14+
"abstract": false,
15+
"type": "Bundle",
16+
"baseDefinition": "http://hl7.org/fhir/StructureDefinition/Bundle",
17+
"derivation": "constraint",
18+
"differential": {
19+
"element": [
20+
{
21+
"id": "Bundle.entry",
22+
"path": "Bundle.entry",
23+
"slicing": {
24+
"discriminator": [
25+
{
26+
"type": "type",
27+
"path": "resource"
28+
}
29+
],
30+
"rules": "open",
31+
"ordered": false
32+
}
33+
},
34+
{
35+
"id": "Bundle.entry:PatientEntry",
36+
"path": "Bundle.entry",
37+
"sliceName": "PatientEntry",
38+
"min": 1,
39+
"max": "1",
40+
"mustSupport": true
41+
},
42+
{
43+
"id": "Bundle.entry:PatientEntry.resource",
44+
"path": "Bundle.entry.resource",
45+
"min": 1,
46+
"type": [
47+
{
48+
"code": "Patient"
49+
}
50+
]
51+
},
52+
{
53+
"id": "Bundle.entry:OrganizationEntry",
54+
"path": "Bundle.entry",
55+
"sliceName": "OrganizationEntry",
56+
"min": 0,
57+
"max": "*"
58+
},
59+
{
60+
"id": "Bundle.entry:OrganizationEntry.resource",
61+
"path": "Bundle.entry.resource",
62+
"min": 1,
63+
"type": [
64+
{
65+
"code": "Organization"
66+
}
67+
]
68+
}
69+
]
70+
}
71+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"include": ["."]
4+
}

src/api/writer-generator/typescript/profile-slices.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export type SliceDef = {
6767
excluded: string[];
6868
array: boolean;
6969
constrainedChoice: ConstrainedChoiceInfo | undefined;
70+
/** True when the slice uses a type discriminator (match by resourceType) */
71+
typeDiscriminator: boolean;
7072
};
7173

7274
export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema): SliceDef[] =>
@@ -77,6 +79,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT
7779
const baseType = tsTypeFromIdentifier(field.type);
7880
const pkgName = flatProfile.identifier.package;
7981
const choiceBaseNames = collectChoiceBaseNames(tsIndex, field.type);
82+
const isTypeDisc = field.slicing.discriminator?.some((d) => d.type === "type") ?? false;
8083
return Object.entries(field.slicing.slices)
8184
.filter(([_, slice]) => Object.keys(slice.match ?? {}).length > 0)
8285
.map(([sliceName, slice]) => {
@@ -98,6 +101,7 @@ export const collectSliceDefs = (tsIndex: TypeSchemaIndex, flatProfile: ProfileT
98101
excluded: slice.excluded ?? [],
99102
array: Boolean(field.array),
100103
constrainedChoice,
104+
typeDiscriminator: isTypeDisc,
101105
};
102106
});
103107
});
@@ -193,7 +197,9 @@ export const generateSliceGetters = (
193197
w.line("if (!item || !matchesValue(item, match)) return undefined");
194198
}
195199
w.line("if (mode === 'raw') return item");
196-
if (sliceDef.constrainedChoice) {
200+
if (sliceDef.typeDiscriminator) {
201+
w.line(`return item as ${typeName}`);
202+
} else if (sliceDef.constrainedChoice) {
197203
const cc = sliceDef.constrainedChoice;
198204
w.line(`return unwrapSliceChoice<${typeName}>(item, ${matchKeys}, ${JSON.stringify(cc.variant)})`);
199205
} else {

src/api/writer-generator/typescript/profile.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ const generateProfileHelpersImport = (
225225
imports.push("applySliceMatch", "matchesValue", "setArraySlice", "getArraySlice", "ensureSliceDefaults");
226226
if (extensions.some((ext) => ext.path.split(".").some((s) => s !== "extension"))) imports.push("ensurePath");
227227
if (extensions.some((ext) => ext.isComplex && ext.subExtensions)) imports.push("extractComplexExtension");
228-
if (sliceDefs.length > 0) imports.push("stripMatchKeys");
228+
if (sliceDefs.some((s) => !s.typeDiscriminator)) imports.push("stripMatchKeys");
229229
if (sliceDefs.some((s) => s.constrainedChoice)) imports.push("wrapSliceChoice", "unwrapSliceChoice");
230230
if (extensions.some((ext) => ext.url)) imports.push("isExtension", "getExtensionValue", "pushExtension");
231231
if (Object.keys(flatProfile.fields ?? {}).length > 0)
@@ -564,7 +564,7 @@ const generateSliceInputTypes = (w: TypeScript, flatProfile: ProfileTypeSchema,
564564
const tsProfileName = tsResourceName(flatProfile.identifier);
565565
for (const sliceDef of sliceDefs) {
566566
const typeName = tsSliceFlatTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName);
567-
const matchFields = Object.keys(sliceDef.match);
567+
const matchFields = sliceDef.typeDiscriminator ? [] : Object.keys(sliceDef.match);
568568
const allExcluded = [...new Set([...sliceDef.excluded, ...matchFields])];
569569
if (sliceDef.constrainedChoice) {
570570
const cc = sliceDef.constrainedChoice;

src/typeschema/core/field-builder.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,21 +172,48 @@ const collectDiscriminatorValue = (
172172
collectDiscriminatorValue(element, segments, index + 1, result);
173173
};
174174

175+
/**
176+
* For type discriminators, navigate the discriminator path through schema.elements
177+
* and read the `type` field. If type is a simple name (not a URL), treat as FHIR
178+
* resource type and set `{ <path>: { resourceType: "<type>" } }`.
179+
*/
180+
const computeTypeDiscriminatorMatch = (
181+
path: string,
182+
schema: FHIRSchemaElement,
183+
result: Record<string, unknown>,
184+
): void => {
185+
if (path === "$this") return;
186+
const segments = path.split(".");
187+
let elem: FHIRSchemaElement | undefined = schema;
188+
for (const seg of segments) {
189+
elem = elem?.elements?.[seg];
190+
if (!elem) return;
191+
}
192+
const typeName = elem.type;
193+
if (!typeName || typeName.includes("/")) return;
194+
setNestedValue(result, segments, { resourceType: typeName });
195+
};
196+
175197
/**
176198
* Computes match values by navigating the slice's schema elements along discriminator paths.
177199
* Used when a slice has an empty match but the discriminator values are nested deeper
178200
* (e.g., component slices in BP where the discriminator crosses a nested slicing boundary).
179201
*/
180202
const computeMatchFromSchema = (
181-
discriminators: Array<{ path: string }>,
203+
discriminators: Array<{ type?: string; path: string }>,
182204
schema: FHIRSchemaElement | undefined,
183205
): Record<string, unknown> | undefined => {
184-
if (!schema?.elements || !discriminators || discriminators.length === 0) return undefined;
206+
if (!schema || !discriminators || discriminators.length === 0) return undefined;
185207

186208
const result: Record<string, unknown> = {};
187209
for (const disc of discriminators) {
188-
const segments = disc.path.split(".");
189-
collectDiscriminatorValue(schema, segments, 0, result);
210+
if (disc.type === "type") {
211+
computeTypeDiscriminatorMatch(disc.path, schema, result);
212+
} else {
213+
if (!schema.elements) continue;
214+
const segments = disc.path.split(".");
215+
collectDiscriminatorValue(schema, segments, 0, result);
216+
}
190217
}
191218
return Object.keys(result).length > 0 ? result : undefined;
192219
};

0 commit comments

Comments
 (0)