Validation Pipeline
Problem
FHIR resources flow through three different trust boundaries: data your
server sends back, data your code is about to write, and data coming from
an upstream integration. Each wants a different validation pass. This
recipe wires all three using the same Standard Schema v1 surface — your
generated validator runs client-side for reads/writes, and $validate
asks the server to re-check terminology.
Prerequisites
- Generated client at
./fhir/r4with--validator nativeor--validator zod - Packages:
@fhir-dsl/core - Server: must implement
$validate(HAPI does; most production servers do). Client-side validation works against any server.
Steps
1. Generate validators
Choose native (zero-dep, small bundle) or zod (ecosystem interop):
# Zero-dep, smaller bundle — good for serverless
fhir-gen generate --version r4 --out ./src/fhir --validator native
# Zod, integrates with .refine() chains
fhir-gen generate --version r4 --out ./src/fhir --validator zod
Both emitters conform to Standard Schema v1, so the FhirClient uses
them interchangeably.
2. Wire the schemas on the client
FhirClientConfig.schemas takes a SchemaRegistry. The generator
exports one from ./fhir/r4/schemas/index.ts keyed by resource type
and profile id.
import { createFhirClient } from "@fhir-dsl/core";
import { schemas } from "./fhir/r4/schemas/index.js";
import type { GeneratedSchema } from "./fhir/r4/client.js";
const fhir = createFhirClient<GeneratedSchema>({
baseUrl: "https://fhir.example/r4",
auth: { type: "bearer", credentials: process.env.TOKEN! },
schemas,
});
3. Validate on read
.validate() is a lazy builder method; it runs at execute() time,
resource-by-resource, and throws ValidationError on the first
non-conformant match entry.
import { ValidationError, ValidationUnavailableError } from "@fhir-dsl/core";
try {
const patient = await fhir.read("Patient", "example").validate().execute();
// ... use typed patient
} catch (e) {
if (e instanceof ValidationError) {
for (const issue of e.issues) {
console.error(issue.path?.join("."), "—", issue.message);
}
} else if (e instanceof ValidationUnavailableError) {
console.error("No schema registry configured");
} else {
throw e;
}
}
4. Validate on search results
Same surface. .validate() on a search iterates every match entry;
included-mode entries are skipped (validate those separately if needed).
const page = await fhir
.search("Observation")
.where("code", "eq", "8480-6")
.count(50)
.validate()
.execute();
5. Interpret StandardValidateResult issues directly
If you want to validate a resource you constructed locally (without a
server round-trip), pull the schema out of the registry and call it
yourself. StandardSchemaLike["~standard"].validate returns either
{ value } on success or { issues } on failure.
import { resolveSchema, validateOne, ValidationError } from "@fhir-dsl/core";
import { schemas } from "./fhir/r4/schemas/index.js";
const schema = resolveSchema(schemas, "Patient");
if (schema) {
try {
const validPatient = await validateOne(schema, {
resourceType: "Patient",
id: "local-1",
name: [{ family: "Doe" }],
gender: "female",
});
} catch (e) {
if (e instanceof ValidationError) {
for (const { message, path } of e.issues) {
console.error(`${(path ?? []).join(".") || "<root>"}: ${message}`);
}
}
}
}
validateOne awaits the Standard Schema v1 result and throws
ValidationError on any issues; the issues list is readonly and each
entry carries { message, path? }.
6. Server-side $validate for writes
Local validators check structure and cardinality. Terminology bindings live on the server — a code your local schema accepts may not be in the ValueSet the server enforces. Ask the server before writing.
const outcome = await fhir.operation("$validate", {
scope: { kind: "type", resourceType: "Patient" },
parameters: {
resource: {
resourceType: "Patient",
name: [{ family: "Doe", given: ["Jane"] }],
gender: "female",
birthDate: "1990-05-15",
},
mode: "create",
},
}).execute();
// outcome is OperationOutcome; inspect .issue for severity/code/diagnostics
Any parameter whose value has a resourceType is carried as
parameter.resource inside the Parameters body; primitive parameters
become valueString / valueInteger etc. The default method is POST —
pass method: "GET" if and only if all your parameters are primitive.
Final snippet
import {
createFhirClient,
resolveSchema,
validateOne,
ValidationError,
ValidationUnavailableError,
} from "@fhir-dsl/core";
import { schemas } from "./fhir/r4/schemas/index.js";
import type { GeneratedSchema } from "./fhir/r4/client.js";
const fhir = createFhirClient<GeneratedSchema>({
baseUrl: "https://fhir.example/r4",
auth: { type: "bearer", credentials: process.env.TOKEN! },
schemas,
});
type PatientDraft = {
resourceType: "Patient";
name: Array<{ family?: string; given?: string[] }>;
gender?: string;
birthDate?: string;
};
export async function safeCreatePatient(draft: PatientDraft) {
// 1. Client-side: structure + cardinality
const schema = resolveSchema(schemas, "Patient");
if (schema) {
await validateOne(schema, draft); // throws ValidationError
}
// 2. Server-side: terminology
const outcome = (await fhir.operation("$validate", {
scope: { kind: "type", resourceType: "Patient" },
parameters: { resource: draft, mode: "create" },
}).execute()) as {
resourceType: "OperationOutcome";
issue: Array<{ severity: string; diagnostics?: string }>;
};
const hardErrors = outcome.issue.filter(
(i) => i.severity === "error" || i.severity === "fatal",
);
if (hardErrors.length > 0) {
throw new Error(
`Server $validate rejected: ${hardErrors.map((i) => i.diagnostics).join("; ")}`,
);
}
// 3. Create. .validate() on the response guards against a surprise
// server mutation that breaks the schema you trusted seconds ago.
return fhir.create(draft).execute();
}
export async function safeReadPatient(id: string) {
try {
return await fhir.read("Patient", id).validate().execute();
} catch (e) {
if (e instanceof ValidationUnavailableError) {
// Misconfig — fall back to unvalidated read rather than crash hard
return fhir.read("Patient", id).execute();
}
if (e instanceof ValidationError) {
console.warn(`Server returned non-conformant Patient/${id}`, e.issues);
throw e;
}
throw e;
}
}
Troubleshooting
ValidationUnavailableErrorthrown atexecute()→schemasnot wired onFhirClientConfig. The registry lookup is lazy and fails only when a validation terminal actually runs.ValidationErroron every response → your generator is a version behind the server. Regenerate against the same FHIR version the server advertises in its CapabilityStatement.- Server
$validatesucceeds but writes fail → the server writes may enforce stricter business rules than$validate(e.g. uniqueness constraints). Treat$validateas a pre-flight smoke test, not a guarantee. - Zod schemas are big in the bundle → switch to
--validator native. The native emitter has no dependencies and tree-shakes to just the resources you import. - Profile validation not picked up → pass the profile to
search()(e.g.search("Patient", "us-core-patient")). Resource-level validation is the default; profile validation kicks in only when the profile name is supplied.