From 4064bdcd833777b9850ebbd034972848f2d99025 Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 22 Apr 2026 14:01:31 +0200 Subject: [PATCH 1/2] TS: generate static is() type guard on profile classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a cheap, non-throwing type guard to generated profile classes so callers can filter collections without hand-rolling resourceType predicates or relying on from() throwing. Emitted only when the profile can be meaningfully discriminated: - resource profiles with a meta field — check resourceType + meta.profile - extension profiles — check url === canonicalUrl Skipped otherwise, since a check of only resourceType (or always-true) would be a misleading guard. --- .../writer-generator/typescript/profile.ts | 14 +++++++ .../__snapshots__/typescript.test.ts.snap | 40 +++++++++++++++++++ .../__snapshots__/local-package.test.ts.snap | 7 ++++ 3 files changed, 61 insertions(+) diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 613bef65..d7057daf 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -391,6 +391,20 @@ const generateFactoryMethods = ( w.lineSM("return profile"); }); w.line(); + const canEmitIs = (hasMeta && isResourceIdentifier(flatProfile.base)) || flatProfile.base.name === "Extension"; + if (canEmitIs) { + w.curlyBlock(["static", "is", "(resource: unknown)", `: resource is ${tsBaseResourceName}`], () => { + w.line(`if (typeof resource !== "object" || resource === null) return false;`); + if (hasMeta && isResourceIdentifier(flatProfile.base)) { + w.line(`const r = resource as { resourceType?: string; meta?: { profile?: string[] } };`); + w.line(`if (r.resourceType !== ${JSON.stringify(flatProfile.base.name)}) return false;`); + w.lineSM(`return (r.meta?.profile ?? []).includes(${profileClassName}.canonicalUrl)`); + } else { + w.lineSM(`return (resource as { url?: string }).url === ${profileClassName}.canonicalUrl`); + } + }); + w.line(); + } w.curlyBlock(["static", "apply", `(resource: ${tsBaseResourceName})`, `: ${profileClassName}`], () => { if (hasMeta) { w.lineSM(`ensureProfile(resource, ${profileClassName}.canonicalUrl)`); diff --git a/test/api/write-generator/__snapshots__/typescript.test.ts.snap b/test/api/write-generator/__snapshots__/typescript.test.ts.snap index e6153df9..3098279f 100644 --- a/test/api/write-generator/__snapshots__/typescript.test.ts.snap +++ b/test/api/write-generator/__snapshots__/typescript.test.ts.snap @@ -375,6 +375,13 @@ export class observation_bodyweightProfile { return profile; } + static is (resource: unknown) : resource is Observation { + if (typeof resource !== "object" || resource === null) return false; + const r = resource as { resourceType?: string; meta?: { profile?: string[] } }; + if (r.resourceType !== "Observation") return false; + return (r.meta?.profile ?? []).includes(observation_bodyweightProfile.canonicalUrl); + } + static apply (resource: Observation) : observation_bodyweightProfile { ensureProfile(resource, observation_bodyweightProfile.canonicalUrl); Object.assign(resource, { @@ -607,6 +614,13 @@ export class observation_bpProfile { return profile; } + static is (resource: unknown) : resource is Observation { + if (typeof resource !== "object" || resource === null) return false; + const r = resource as { resourceType?: string; meta?: { profile?: string[] } }; + if (r.resourceType !== "Observation") return false; + return (r.meta?.profile ?? []).includes(observation_bpProfile.canonicalUrl); + } + static apply (resource: Observation) : observation_bpProfile { ensureProfile(resource, observation_bpProfile.canonicalUrl); Object.assign(resource, { @@ -897,6 +911,13 @@ export class USCorePatientProfile { return profile; } + static is (resource: unknown) : resource is Patient { + if (typeof resource !== "object" || resource === null) return false; + const r = resource as { resourceType?: string; meta?: { profile?: string[] } }; + if (r.resourceType !== "Patient") return false; + return (r.meta?.profile ?? []).includes(USCorePatientProfile.canonicalUrl); + } + static apply (resource: Patient) : USCorePatientProfile { ensureProfile(resource, USCorePatientProfile.canonicalUrl); return new USCorePatientProfile(resource); @@ -1168,6 +1189,13 @@ export class USCoreBloodPressureProfile { return profile; } + static is (resource: unknown) : resource is Observation { + if (typeof resource !== "object" || resource === null) return false; + const r = resource as { resourceType?: string; meta?: { profile?: string[] } }; + if (r.resourceType !== "Observation") return false; + return (r.meta?.profile ?? []).includes(USCoreBloodPressureProfile.canonicalUrl); + } + static apply (resource: Observation) : USCoreBloodPressureProfile { ensureProfile(resource, USCoreBloodPressureProfile.canonicalUrl); Object.assign(resource, { @@ -1550,6 +1578,13 @@ export class USCoreBodyWeightProfile { return profile; } + static is (resource: unknown) : resource is Observation { + if (typeof resource !== "object" || resource === null) return false; + const r = resource as { resourceType?: string; meta?: { profile?: string[] } }; + if (r.resourceType !== "Observation") return false; + return (r.meta?.profile ?? []).includes(USCoreBodyWeightProfile.canonicalUrl); + } + static apply (resource: Observation) : USCoreBodyWeightProfile { ensureProfile(resource, USCoreBodyWeightProfile.canonicalUrl); Object.assign(resource, { @@ -1881,6 +1916,11 @@ export class USCoreRaceExtensionProfile { return profile; } + static is (resource: unknown) : resource is Extension { + if (typeof resource !== "object" || resource === null) return false; + return (resource as { url?: string }).url === USCoreRaceExtensionProfile.canonicalUrl; + } + static apply (resource: Extension) : USCoreRaceExtensionProfile { resource.url = USCoreRaceExtensionProfile.canonicalUrl; Object.assign(resource, { 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 3dc9737b..ed2d9111 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 @@ -92,6 +92,13 @@ export class ExampleTypedBundleProfile { return profile; } + static is (resource: unknown) : resource is Bundle { + if (typeof resource !== "object" || resource === null) return false; + const r = resource as { resourceType?: string; meta?: { profile?: string[] } }; + if (r.resourceType !== "Bundle") return false; + return (r.meta?.profile ?? []).includes(ExampleTypedBundleProfile.canonicalUrl); + } + static apply (resource: Bundle) : ExampleTypedBundleProfile { ensureProfile(resource, ExampleTypedBundleProfile.canonicalUrl); return new ExampleTypedBundleProfile(resource); From df08c5c1b9c9143b2f0738b999d261b89f0bf13d Mon Sep 17 00:00:00 2001 From: Aleksandr Penskoi Date: Wed, 22 Apr 2026 14:01:36 +0200 Subject: [PATCH 2/2] TS: regenerate R4 profile examples and add is() demos - Regenerated profile classes for R4 examples (Observation profiles and Extension profiles) pick up the new is() method. - Added demo + regression tests for is() showing the filter pattern from issue #146. --- .../typescript-r4/extension-profile.test.ts | 9 +++ .../profiles/Extension_birthPlace.ts | 5 ++ .../profiles/Extension_birthTime.ts | 5 ++ .../profiles/Extension_nationality.ts | 5 ++ .../profiles/Extension_own_prefix.ts | 5 ++ .../Observation_observation_bodyweight.ts | 7 ++ .../profiles/Observation_observation_bp.ts | 7 ++ .../Observation_observation_vitalsigns.ts | 7 ++ .../typescript-r4/profile-bodyweight.test.ts | 67 +++++++++++++++++++ 9 files changed, 117 insertions(+) diff --git a/examples/typescript-r4/extension-profile.test.ts b/examples/typescript-r4/extension-profile.test.ts index 1dee2811..42c428b4 100644 --- a/examples/typescript-r4/extension-profile.test.ts +++ b/examples/typescript-r4/extension-profile.test.ts @@ -74,3 +74,12 @@ test("create() with no required params", () => { const profile = nationalityProfile.create(); expect(profile.toResource().url).toBe("http://hl7.org/fhir/StructureDefinition/patient-nationality"); }); + +test("is() matches extensions by url", () => { + const ext = birthPlaceProfile.createResource({ valueAddress: { city: "Bonn" } }); + expect(birthPlaceProfile.is(ext)).toBe(true); + expect(birthTimeProfile.is(ext)).toBe(false); + expect(birthPlaceProfile.is({ url: "http://example.com/other" })).toBe(false); + expect(birthPlaceProfile.is(null)).toBe(false); + expect(birthPlaceProfile.is("not an object")).toBe(false); +}); diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts index e2729645..11c3994e 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts @@ -38,6 +38,11 @@ export class birthPlaceProfile { return profile; } + static is (resource: unknown) : resource is Extension { + if (typeof resource !== "object" || resource === null) return false; + return (resource as { url?: string }).url === birthPlaceProfile.canonicalUrl; + } + static apply (resource: Extension) : birthPlaceProfile { resource.url = birthPlaceProfile.canonicalUrl; Object.assign(resource, { diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts index 088028b9..53e7f2b0 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts @@ -37,6 +37,11 @@ export class birthTimeProfile { return profile; } + static is (resource: unknown) : resource is Extension { + if (typeof resource !== "object" || resource === null) return false; + return (resource as { url?: string }).url === birthTimeProfile.canonicalUrl; + } + static apply (resource: Extension) : birthTimeProfile { resource.url = birthTimeProfile.canonicalUrl; Object.assign(resource, { diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts index 3990ad4e..bec4decc 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts @@ -49,6 +49,11 @@ export class nationalityProfile { return profile; } + static is (resource: unknown) : resource is Extension { + if (typeof resource !== "object" || resource === null) return false; + return (resource as { url?: string }).url === nationalityProfile.canonicalUrl; + } + static apply (resource: Extension) : nationalityProfile { resource.url = nationalityProfile.canonicalUrl; Object.assign(resource, { diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts index eeb18eb6..8e81b487 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts @@ -37,6 +37,11 @@ export class own_prefixProfile { return profile; } + static is (resource: unknown) : resource is Extension { + if (typeof resource !== "object" || resource === null) return false; + return (resource as { url?: string }).url === own_prefixProfile.canonicalUrl; + } + static apply (resource: Extension) : own_prefixProfile { resource.url = own_prefixProfile.canonicalUrl; Object.assign(resource, { diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts index a6fa90ea..362bf460 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts @@ -61,6 +61,13 @@ export class observation_bodyweightProfile { return profile; } + static is (resource: unknown) : resource is Observation { + if (typeof resource !== "object" || resource === null) return false; + const r = resource as { resourceType?: string; meta?: { profile?: string[] } }; + if (r.resourceType !== "Observation") return false; + return (r.meta?.profile ?? []).includes(observation_bodyweightProfile.canonicalUrl); + } + static apply (resource: Observation) : observation_bodyweightProfile { ensureProfile(resource, observation_bodyweightProfile.canonicalUrl); Object.assign(resource, { diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts index d303dd4a..6e0655f4 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts @@ -76,6 +76,13 @@ export class observation_bpProfile { return profile; } + static is (resource: unknown) : resource is Observation { + if (typeof resource !== "object" || resource === null) return false; + const r = resource as { resourceType?: string; meta?: { profile?: string[] } }; + if (r.resourceType !== "Observation") return false; + return (r.meta?.profile ?? []).includes(observation_bpProfile.canonicalUrl); + } + static apply (resource: Observation) : observation_bpProfile { ensureProfile(resource, observation_bpProfile.canonicalUrl); Object.assign(resource, { diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts index 146c333d..2ddde60d 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts @@ -61,6 +61,13 @@ export class observation_vitalsignsProfile { return profile; } + static is (resource: unknown) : resource is Observation { + if (typeof resource !== "object" || resource === null) return false; + const r = resource as { resourceType?: string; meta?: { profile?: string[] } }; + if (r.resourceType !== "Observation") return false; + return (r.meta?.profile ?? []).includes(observation_vitalsignsProfile.canonicalUrl); + } + static apply (resource: Observation) : observation_vitalsignsProfile { ensureProfile(resource, observation_vitalsignsProfile.canonicalUrl); resource.category = ensureSliceDefaults( diff --git a/examples/typescript-r4/profile-bodyweight.test.ts b/examples/typescript-r4/profile-bodyweight.test.ts index dac9b017..18c65da6 100644 --- a/examples/typescript-r4/profile-bodyweight.test.ts +++ b/examples/typescript-r4/profile-bodyweight.test.ts @@ -108,6 +108,73 @@ describe("demo: read a bodyweight observation from JSON", () => { }); }); +describe("demo: filter a mixed collection with is()", () => { + test("is() narrows unknown values to profile matches", () => { + // A mixed bag: a matching bodyweight, a non-matching Observation, a non-Observation, and junk + const bodyweight: Observation = { + resourceType: "Observation", + meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bodyweight"] }, + status: "final", + code: { coding: [{ code: "29463-7", system: "http://loinc.org" }] }, + subject: { reference: "Patient/pt-1" }, + effectiveDateTime: "2024-06-15", + valueQuantity: { value: 75, unit: "kg" }, + }; + const otherObs: Observation = { + resourceType: "Observation", + status: "final", + code: { coding: [{ code: "8867-4", system: "http://loinc.org" }] }, + }; + const resources: unknown[] = [bodyweight, otherObs, { resourceType: "Patient" }, null, "not a resource"]; + + // is() is a cheap type guard — no validation, no instance constructed + const matches = resources.filter(bodyweightProfile.is); + + expect(matches).toEqual([bodyweight]); + }); +}); + +describe("is() type guard", () => { + test("returns true for Observation with matching meta.profile", () => { + const obs: Observation = { + resourceType: "Observation", + meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bodyweight"] }, + status: "final", + code: { coding: [{ code: "29463-7", system: "http://loinc.org" }] }, + }; + expect(bodyweightProfile.is(obs)).toBe(true); + }); + + test("returns false when meta.profile does not include canonical url", () => { + const obs: Observation = { + resourceType: "Observation", + status: "final", + code: { coding: [{ code: "29463-7", system: "http://loinc.org" }] }, + }; + expect(bodyweightProfile.is(obs)).toBe(false); + }); + + test("returns false for wrong resourceType", () => { + expect(bodyweightProfile.is({ resourceType: "Patient" })).toBe(false); + }); + + test("returns false for non-object inputs", () => { + expect(bodyweightProfile.is(null)).toBe(false); + expect(bodyweightProfile.is(undefined)).toBe(false); + expect(bodyweightProfile.is("string")).toBe(false); + expect(bodyweightProfile.is(42)).toBe(false); + }); + + test("does not validate — only checks identity", () => { + // A resource that claims the profile but is otherwise invalid still passes is() + const bogus = { + resourceType: "Observation", + meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bodyweight"] }, + }; + expect(bodyweightProfile.is(bogus)).toBe(true); + }); +}); + describe("factory method equivalence", () => { test("create(), createResource(), and apply() produce equal resources", () => { const args = { status: "final" as const, subject: { reference: "Patient/pt-1" as const } };