npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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-validator

It 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-progress checks structure only: known linkIds, nesting, value types and formats (base64 attachment data included), cardinality and choice membership.
  • completed also checks enableWhen, required, occurrences, value limits, lengths, attachments, exclusive options and targetConstraints.

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 throw InputLimitError; validateResponse returns an input-limit issue.
  • 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