Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9325b30
Add constrained choice detection and deduplicate extensions by url
ryukzak Mar 12, 2026
144b2d9
Add writer infrastructure: ifElseChain, lineWidth, naming helpers
ryukzak Mar 12, 2026
298eaaf
Add runtime helpers for multi-form extension input resolution
ryukzak Mar 12, 2026
9c165dc
Refactor profile generation: extract modules, add from/apply split
ryukzak Mar 12, 2026
24c545f
Add US Core example tests, snapshot tests, and documentation
ryukzak Mar 12, 2026
c6d985e
Regenerate examples
ryukzak Mar 12, 2026
672fe31
Remove toProfile(), combine imports by module, remove dead helpers
ryukzak Mar 12, 2026
d5815db
Update tests and snapshots
ryukzak Mar 12, 2026
68a4c4f
Regenerate examples
ryukzak Mar 12, 2026
a83dfd7
Unify slice and extension getter/setter API
ryukzak Mar 12, 2026
fde404c
Update tests and snapshots
ryukzak Mar 12, 2026
fd9991e
Regenerate examples
ryukzak Mar 12, 2026
cdc56be
Update root README with unified slice getter API
ryukzak Mar 12, 2026
cbcd359
Return { errors, warnings } from validate(), enforce extensible bindi…
ryukzak Mar 12, 2026
ef635f4
Update tests and snapshots
ryukzak Mar 12, 2026
eb7b2b4
Regenerate examples
ryukzak Mar 12, 2026
52e3031
Add must-support field warnings to validate()
ryukzak Mar 12, 2026
390e1ef
Update tests and snapshots
ryukzak Mar 12, 2026
05a13b6
Update docs and posts for must-support warnings and { errors, warning…
ryukzak Mar 12, 2026
ac130ab
Regenerate examples
ryukzak Mar 12, 2026
65a5e8a
Actualize profiles design doc for current API
ryukzak Mar 12, 2026
1458cbd
Reuse canonicalUrl static field instead of inline URL literals
ryukzak Mar 12, 2026
4468f51
Update tests and snapshots
ryukzak Mar 12, 2026
58694e9
Regenerate examples
ryukzak Mar 12, 2026
236d1be
Move tsImport options argument to last position
ryukzak Mar 12, 2026
a199c3c
Replace demo files with tests, update US Core README
ryukzak Mar 12, 2026
5870dd8
Add base type links to working examples in US Core post
ryukzak Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ FHIR Package → TypeSchema Generator → TypeSchema Format → Code Generators
- Avoid `function foo() { ... }` declarations in new code
- Avoid re-exports inside project
- Avoid `interface Foo { ... }` declarations in new code, prefer type syntax if it is possible
- In code generators (writer-generator): use `curlyBlock` and `squareBlock` helpers for writing structured output instead of manual indent/deindent or string concatenation
- Use `Record` instead of `Map` unless there is a significant reason for `Map` (e.g. non-string keys, iteration order guarantees, frequent deletion)
- Prefer single-line guard clauses without braces: `if (!x) throw new Error("...");` instead of wrapping in `{ }`

### Testing Strategy
- Uses Bun's built-in test runner
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,11 +343,11 @@ const obs = bp.toResource();
**Slicing & Choice Type Flattening:**

```typescript
// Simplified getter — discriminator stripped, choice type flattened
bp.getSystolicBP(); // { value: 120, unit: "mmHg" }
// Flat getter (default) — discriminator stripped, choice type flattened
bp.getSystolicBP(); // { value: 120, unit: "mmHg" }

// Raw getter — full FHIR element including discriminator values
bp.getSystolicBPRaw(); // { code: { coding: [...] }, valueQuantity: { value: 120, ... } }
bp.getSystolicBP('raw'); // { code: { coding: [...] }, valueQuantity: { value: 120, ... } }
```

**Wrapping Existing Resources:**
Expand Down
110 changes: 73 additions & 37 deletions assets/api/writer-generator/typescript/profile-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,42 @@ export const matchesValue = (value: unknown, match: unknown): boolean => {
return value === match;
};

/**
* Type guard that discriminates a raw extension input (with an `extension`
* array) from a flat-API input object. Using a custom type guard instead of
* a bare `"extension" in args` lets TypeScript narrow *both* branches of the
* union — the plain `in` check cannot eliminate a type whose `extension`
* property is optional.
*/
export const isRawExtensionInput = <TRaw extends object>(input: object): input is TRaw => "extension" in input;

/**
* Type guard that tests whether an unknown setter input is a raw Extension
* (i.e. an object with a `url` property). When `url` is provided, also
* checks that the extension's URL matches the expected value.
*/
export const isExtension = <E extends { url: string }>(input: unknown, url?: string): input is E =>
typeof input === "object" && input !== null && "url" in input && (url === undefined || input.url === url);

/**
* Read a single typed value field from an Extension, returning `undefined`
* when the extension itself is absent or the field is not set.
*
* This avoids the double-cast `(ext as Record<…>)?.field as T` that would
* otherwise be needed for value fields not declared on the base Extension type.
*/
export const getExtensionValue = <T>(ext: { url?: string } | undefined, field: string): T | undefined => {
if (!ext) return undefined;
return (ext as Record<string, unknown>)[field] as T | undefined;
};

/**
* Push an extension onto `target.extension`, creating the array if absent.
*/
export const pushExtension = <E extends { url?: string }>(target: { extension?: E[] }, ext: E): void => {
(target.extension ??= []).push(ext);
};

// ---------------------------------------------------------------------------
// Extension helpers
// ---------------------------------------------------------------------------
Expand All @@ -134,10 +170,10 @@ export const matchesValue = (value: unknown, match: unknown): boolean => {
* @returns A record keyed by sub-extension URL, or `undefined` if the
* extension has no nested children.
*/
export const extractComplexExtension = (
extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined,
export const extractComplexExtension = <T = Record<string, unknown>>(
extension: { extension?: Array<{ url?: string }> } | undefined,
config: Array<{ name: string; valueField: string; isArray: boolean }>,
): Record<string, unknown> | undefined => {
): T | undefined => {
if (!extension?.extension) return undefined;
const result: Record<string, unknown> = {};
for (const { name, valueField, isArray } of config) {
Expand All @@ -148,7 +184,7 @@ export const extractComplexExtension = (
result[name] = (subExts[0] as Record<string, unknown>)[valueField];
}
}
return result;
return result as T;
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -219,6 +255,13 @@ export const ensureSliceDefaults = <T>(items: T[], ...matches: Record<string, un
return items;
};

/**
* Cast an object literal to a FHIR resource type. This centralises the single
* `as unknown as T` that is unavoidable when constructing a resource from a
* plain object (deep inheritance prevents direct structural compatibility).
*/
export const buildResource = <T>(obj: object): T => obj as unknown as T;

/**
* Add `canonicalUrl` to `resource.meta.profile` if not already present.
* Creates `meta` and `profile` when missing.
Expand Down Expand Up @@ -256,25 +299,31 @@ export const getArraySlice = <T>(list: readonly T[] | undefined, match: Record<s
// ---------------------------------------------------------------------------

/** Checks that `field` is present (not `undefined` or `null`). */
export const validateRequired = (res: Record<string, unknown>, profileName: string, field: string): string[] => {
return res[field] === undefined || res[field] === null
export const validateRequired = (res: object, profileName: string, field: string): string[] => {
const rec = res as Record<string, unknown>;
return rec[field] === undefined || rec[field] === null
? [`${profileName}: required field '${field}' is missing`]
: [];
};

/** Checks that a must-support field is populated (warning, not error). */
export const validateMustSupport = (res: object, profileName: string, field: string): string[] => {
const rec = res as Record<string, unknown>;
return rec[field] === undefined || rec[field] === null
? [`${profileName}: must-support field '${field}' is not populated`]
: [];
};

/** Checks that `field` is absent (profiles may exclude base fields). */
export const validateExcluded = (res: Record<string, unknown>, profileName: string, field: string): string[] => {
return res[field] !== undefined ? [`${profileName}: field '${field}' must not be present`] : [];
export const validateExcluded = (res: object, profileName: string, field: string): string[] => {
return (res as Record<string, unknown>)[field] !== undefined
? [`${profileName}: field '${field}' must not be present`]
: [];
};

/** Checks that `field` structurally contains the expected fixed value. */
export const validateFixedValue = (
res: Record<string, unknown>,
profileName: string,
field: string,
expected: unknown,
): string[] => {
return matchesValue(res[field], expected)
export const validateFixedValue = (res: object, profileName: string, field: string, expected: unknown): string[] => {
return matchesValue((res as Record<string, unknown>)[field], expected)
? []
: [`${profileName}: field '${field}' does not match expected fixed value`];
};
Expand All @@ -284,15 +333,15 @@ export const validateFixedValue = (
* discriminator) falls within [`min`, `max`]. Pass `max = 0` for unbounded.
*/
export const validateSliceCardinality = (
res: Record<string, unknown>,
res: object,
profileName: string,
field: string,
match: Record<string, unknown>,
sliceName: string,
min: number,
max: number,
): string[] => {
const items = res[field] as unknown[] | undefined;
const items = (res as Record<string, unknown>)[field] as unknown[] | undefined;
const count = (items ?? []).filter((item) => matchesValue(item, match)).length;
const errors: string[] = [];
if (count < min) {
Expand All @@ -308,12 +357,9 @@ export const validateSliceCardinality = (
* Checks that at least one of the listed choice-type variants is present.
* E.g. `["effectiveDateTime", "effectivePeriod"]`.
*/
export const validateChoiceRequired = (
res: Record<string, unknown>,
profileName: string,
choices: string[],
): string[] => {
return choices.some((c) => res[c] !== undefined)
export const validateChoiceRequired = (res: object, profileName: string, choices: string[]): string[] => {
const rec = res as Record<string, unknown>;
return choices.some((c) => rec[c] !== undefined)
? []
: [`${profileName}: at least one of ${choices.join(", ")} is required`];
};
Expand All @@ -323,13 +369,8 @@ export const validateChoiceRequired = (
* Handles plain strings, Coding objects, and CodeableConcept objects.
* Skips validation when the field is absent.
*/
export const validateEnum = (
res: Record<string, unknown>,
profileName: string,
field: string,
allowed: string[],
): string[] => {
const value = res[field];
export const validateEnum = (res: object, profileName: string, field: string, allowed: string[]): string[] => {
const value = (res as Record<string, unknown>)[field];
if (value === undefined || value === null) return [];
if (typeof value === "string") {
return allowed.includes(value)
Expand Down Expand Up @@ -357,13 +398,8 @@ export const validateEnum = (
* types. Extracts the type from the `reference` string (the part before
* the first `/`). Skips validation when the field or reference is absent.
*/
export const validateReference = (
res: Record<string, unknown>,
profileName: string,
field: string,
allowed: string[],
): string[] => {
const value = res[field];
export const validateReference = (res: object, profileName: string, field: string, allowed: string[]): string[] => {
const value = (res as Record<string, unknown>)[field];
if (value === undefined || value === null) return [];
const ref = (value as Record<string, unknown>).reference as string | undefined;
if (!ref) return [];
Expand Down
Loading
Loading