@codeness-io/fhir-questionnaire-validator
v0.1.4
Published
Validate FHIR R4 QuestionnaireResponses against their Questionnaire (enableWhen, SDC constraints, dynamic limits) and lint Questionnaires. Isomorphic, synchronous, no network.
Maintainers
Readme
fhir-questionnaire-validator
Validates FHIR R4 QuestionnaireResponses against their Questionnaire, following the core spec and the parts of SDC that affect validity: enableWhen, enableWhenExpression, targetConstraint, dynamic limits through cqf-expression, and more. It also lints Questionnaires before you publish them.
The same code runs in Node and in the browser. It is synchronous and never touches the network.
Status: 0.x. The API may change between minor versions until 1.0. Issue codes and the result shape are public API: changes to them are called out in the changelog.
What it is, and what it is not
It is a validator and rules engine for forms. It answers these questions:
- Is this response valid for this Questionnaire?
- Which items are enabled right now?
- What are the effective min/max values of this item?
- Which answers carry this code?
- Can this Questionnaire be enforced at all?
It is not a form renderer, a FHIR server or a terminology client. It does not populate ($populate), extract ($extract) or assemble ($assemble) forms; for those, see the Apache-2.0 @aehrc/sdc-* packages. It does not resolve canonical URLs, external ValueSets or launchContext.
CONFORMANCE.md lists what is validated, what is ignored on purpose and what is not supported, with results against HL7's official test corpus. DECISIONS.md records how ambiguous parts of the spec are resolved.
Install
npm install @codeness-io/fhir-questionnaire-validatorIt needs Node 20 or later, or any modern browser. The package ships ESM and CommonJS builds with types, which resolve under every TypeScript moduleResolution: node16/nodenext, bundler, and the legacy node (node10, the NestJS default). It depends only on fhirpath (pinned to 5.2.x, because a new FHIRPath engine can change validation results) and @types/fhir.
Public types come from fhir/r4 (@types/fhir, any version from 0.0.33 below 0.1.0, so your own copy is reused instead of installing a second one). The library re-exports the FHIR R4 types its API uses (Questionnaire, QuestionnaireResponse, QuestionnaireItem, QuestionnaireResponseItem, QuestionnaireResponseItemAnswer, Coding, Attachment, OperationOutcome, ...), so you can import them from the library and never depend on @types/fhir yourself:
import type { Questionnaire, QuestionnaireResponse } from '@codeness-io/fhir-questionnaire-validator';Browser bundle size: this library is about 15 KB gzipped once minified. fhirpath and its R4 model account for most of the bundle: roughly 260 KB and 70 KB gzipped, unminified.
Usage
Node (server side)
import { createValidator, toOperationOutcome, type Questionnaire } from '@codeness-io/fhir-questionnaire-validator';
// Create one instance and reuse it: it caches compiled expressions.
const validator = createValidator({ timeZone: 'America/Santiago' });
export function submit(questionnaire: Questionnaire, body: unknown) {
const result = validator.validateResponse(questionnaire, body, { mode: 'completed' });
if (!result.valid) {
// result.issues: [{ severity, code, linkId, expression, repeatPath, message, key?, diagnostics? }]
return { status: 422, body: toOperationOutcome(result) };
}
return { status: 200 };
}CommonJS works the same way:
const { createValidator } = require('@codeness-io/fhir-questionnaire-validator');
const { es } = require('@codeness-io/fhir-questionnaire-validator/locales/es');
const validator = createValidator({ messages: es });validateResponse accepts unknown because it is meant for untrusted input. It never throws for a bad response. Anything that is not a QuestionnaireResponse, is malformed, or is over the input limits comes back as an error issue.
To drop answers the user left behind in sections that are now disabled, prune the response before validating and storing it:
const pruned = validator.pruneDisabledAnswers(questionnaire, response);
const result = validator.validateResponse(questionnaire, pruned, { mode: 'completed' });Browser (React, with any renderer)
import { useMemo, useState } from 'react';
import { createValidator } from '@codeness-io/fhir-questionnaire-validator';
import { es } from '@codeness-io/fhir-questionnaire-validator/locales/es';
import type { Questionnaire, QuestionnaireResponse } from '@codeness-io/fhir-questionnaire-validator';
const validator = createValidator({ messages: es });
function ReportForm({ questionnaire, initial }: { questionnaire: Questionnaire; initial: QuestionnaireResponse }) {
const [response, setResponse] = useState(initial);
const enabled = useMemo(() => validator.getEnabledItems(questionnaire, response), [questionnaire, response]);
const limits = useMemo(() => validator.resolveDynamicValues(questionnaire, response), [questionnaire, response]);
const onSave = () => {
const { issues } = validator.validateResponse(questionnaire, response, { mode: 'in-progress' });
// ...
};
return (
<Renderer
questionnaire={questionnaire}
response={response}
isEnabled={(linkId, repeatPath) => enabled.isEnabled(linkId, repeatPath)}
maxValue={(linkId, repeatPath) => limits.get(linkId, repeatPath)?.maxValue}
onChange={setResponse}
onSave={onSave}
/>
);
}repeatPath identifies the position of an item inside repeating groups. For example, [1] is the second repetition of the enclosing group. getEnabledItems(...).items lists every position. Validation issues carry the same repeatPath, so you can place an error on the right repetition without parsing expression: enabled.isEnabled(issue.linkId, issue.repeatPath). isEnabledAt(issue.expression) also maps an issue back to a response item.
Reading answers by meaning
getAnswersByCodes returns the answers of enabled items whose item.code matches, grouped by code. Business rules can then read answers by meaning instead of by linkId, which changes when a form is edited. One call copies and walks the response once, whatever the number of codes:
// Every code of a system...
const byCode = validator.getAnswersByCodes(questionnaire, response, { system: 'http://loinc.org' });
byCode.get('8480-6');
// [{ linkId, expression, type: 'Decimal', value: 120, answer: { valueDecimal: 120 } }]
// ...or only some codes, of a system ({ system, codes }) or of any system ({ codes }).
validator.getAnswersByCodes(questionnaire, response, { system: 'http://loinc.org', codes: ['8480-6', '8462-4'] });The result is a ReadonlyMap keyed by code, with answers in document order. Codes without answers are absent, and an item carrying several matching codes appears under each. For a single code, getAnswersByCode(questionnaire, response, { system, code }) returns the answer list directly.
Linting Questionnaires
const { valid, issues } = validator.lintQuestionnaire(questionnaire, {
allowedItemControls: [{ system: 'https://example.org/item-control', code: 'switch' }],
allowedExtensions: ['https://example.org/StructureDefinition/presentation-hint'],
unsupportedSeverity: 'error', // or 'warning'
rules: [
// Your own rules, e.g. "every report must ask for a conclusion".
(q, { items }) =>
items.some(({ item }) => item.code?.some((c) => c.code === 'conclusion'))
? []
: [{ severity: 'error', code: 'missing-conclusion', message: 'Add a conclusion item.' }],
],
});Options
createValidator({
messages: es, // or a partial override: { required: (p) => `${p.item} is required`, reasons: { ... } }
limits: { maxItems: 2_000 }, // merged over DEFAULT_LIMITS
now: () => new Date(), // clock for today(), now(), timeOfDay()
timeZone: 'America/Santiago', // zone used to turn the clock into a local date; defaults to the host's
expressionCacheSize: 500, // compiled FHIRPath expressions kept per instance (LRU)
ignoredExtensions: [], // extensions to accept silently during validation
presentationExtensions: [ // layout-only extensions: never validated, accepted by the linter
'https://example.org/fhir/StructureDefinition/', // a trailing `/` matches every URL under it
],
});Renderers often add their own extensions for layout (field widths, row breaks, card styles). Declare them once in presentationExtensions and use the same validator options in the browser and on the server: validation never reports them as unsupported-feature, and lintQuestionnaire accepts them without listing each URL in allowedExtensions. Only declare extensions that cannot change whether a response is valid.
Messages are English by default. A Spanish catalog ships as es, exported from the main entry and from ./locales/es (en likewise). Locales are plain objects, so you pass them explicitly and nothing registers itself. You can override any message per issue code. The human text of a targetConstraint always wins over the catalog.
When an issue code has several possible causes (invalid-structure, expression-error, invalid-options, ...), the cause is a reason code (IssueReason, e.g. answer-not-array) that each catalog translates in its reasons. The MessageCatalog type requires every reason, so a locale's messages never fall back to another language; code formatters receive the rendered reason as reasonText. Untranslated technical text, such as the FHIRPath engine's error message, never goes into message: it is in the issue's optional diagnostics (and in OperationOutcome.issue.diagnostics).
The clock is read once per call, so every expression in one validation sees the same instant. Pass a fixed clock in tests.
Issues
Every issue has a stable code, a severity (error or warning), the item's linkId, and an expression: the FHIRPath location of the problem, for example QuestionnaireResponse.item[0].item[2].answer[0]. When an item is missing, expression points to the element where it should be. valid is true when there is no error.
Issues about an item of the response also have a repeatPath, the same position used by isEnabled(linkId, repeatPath) and resolveDynamicValues().get(linkId, repeatPath): the repetition index of every enclosing repeating group (or repeating question with nested items), outermost first. A missing required answer in the second repetition of a group has repeatPath: [1]. An issue about one repetition of a repeating group itself (its targetConstraint, answers to it while disabled, a structural problem of that repetition) ends with that repetition's index; issues about the group as a whole (required, min-occurs, max-occurs) do not. toOperationOutcome does not carry repeatPath: OperationOutcome has no element for it, and expression already locates the issue.
Two modes follow QuestionnaireResponse.status:
in-progresschecks structure only: known linkIds, nesting, value types and formats (base64 attachmentdataincluded), cardinality and choice membership.completedalso checksenableWhen,required, occurrences, value limits, lengths, attachments, exclusive options andtargetConstraints.
The full list of codes is in CONFORMANCE.md.
Limits and security
Questionnaire authors write the FHIRPath expressions, not you, and FHIRPath evaluation in Node blocks the event loop. The library defends itself in two layers, both on by default:
- Input limits are checked before anything is evaluated: response size in bytes, number of items, repetitions per group, answers per item, string length and nesting depth. See
DEFAULT_LIMITS. Data-returning methods throwInputLimitError;validateResponsereturns aninput-limitissue. - Expression limits cap expression length, syntax-tree size and depth. Regexes in
matches()/replaceMatches()with nested quantifiers are rejected:'aaaaaaaaaaaaaaaaaaaaaaaaaaaa!'.matches('^(a+)+$')takes about 9 s in fhirpath.js. Such expressions fail closed at validation, and the linter reports them before publication.
Evaluation is synchronous on purpose: resolve(), memberOf() and other network-backed functions cannot run. Inputs are deep-copied first, because fhirpath.js may annotate the objects it walks. Your objects are never mutated.
These layers do not bound every possible expression. If your server validates responses to Questionnaires authored by third parties, also run validation in a worker with a timeout (worker_threads in Node, a Web Worker in browsers).
Please report vulnerabilities privately: see SECURITY.md.
Contributing
See CONTRIBUTING.md. Releases are automated with release-please: see docs/RELEASING.md.
License
Apache-2.0 © Codeness
