@kotaio/adaptive-requirements-engine
v3.1.0
Published
Schema-driven requirements engine with JSON Logic rules for conditional evaluation, validation, and computed fields
Readme
@kotaio/adaptive-requirements-engine
Framework-agnostic engine for evaluating requirement schemas returned by the Adaptive Requirements API. Handles field visibility, validation, computed values, option resolution, and multi-step flow navigation — with zero React or browser dependencies.
When to use this package
- Server-side validation — evaluate the same schemas on the server for parity with client-side validation
- Non-React renderers — build custom form renderers in any framework
- Custom integrations — use the engine functions directly for advanced workflows
If you're building a React application, use @kotaio/adaptive-form instead — it wraps this engine with a ready-made component.
Installation
npm install @kotaio/adaptive-requirements-engineCore concepts
Requirement schemas are returned by the API as RequirementsObject values. A schema contains:
- Fields — input definitions with types, labels, validation rules, and conditional behavior
- Datasets — reusable option lists that fields can reference
- Flow (optional) — step definitions for multi-step forms with conditional navigation
The engine provides pure functions to evaluate these schemas against the current form data. You never need to construct schemas yourself — they come from the API.
Here is an example of what a schema returned by the API looks like — an employee onboarding form:
import type { RequirementsObject } from '@kotaio/adaptive-requirements-engine';
const requirements: RequirementsObject = {
datasets: [
{
id: 'department',
items: [
{ value: 'engineering', label: { default: 'Engineering' }, country: 'global' },
{ value: 'sales_de', label: { default: 'Sales (DE)' }, country: 'de' },
{ value: 'sales_us', label: { default: 'Sales (US)' }, country: 'us' },
{ value: 'support', label: { default: 'Support' }, country: 'global' },
],
},
],
fields: [
// Basic required text field
{
id: 'first_name',
type: 'text',
label: { default: 'First name' },
validation: { required: true },
},
// Select with inline options
{
id: 'country',
type: 'select',
label: { default: 'Country' },
options: [
{ value: 'de', label: { default: 'Germany' } },
{ value: 'us', label: { default: 'United States' } },
{ value: 'gb', label: { default: 'United Kingdom' } },
],
validation: { required: true },
},
// Select from a dataset with filtering — only shows departments matching the selected country
{
id: 'department',
type: 'select',
label: { default: 'Department' },
optionsSource: {
dataset: 'department',
filter: {
or: [
{ '==': [{ var: 'item.country' }, 'global'] },
{ '==': [{ var: 'item.country' }, { var: 'answers.country' }] },
],
},
},
validation: { required: true },
},
// Date field with a validation rule
{
id: 'start_date',
type: 'date',
label: { default: 'Start date' },
validation: {
required: true,
rules: [
{
rule: { '>=': [{ var: 'start_date' }, { today: {} }] },
message: 'Start date must be today or later',
},
],
},
},
// Computed field — automatically derived from other values
{
id: 'needs_visa',
type: 'computed',
readOnly: true,
compute: { '!=': [{ var: 'answers.country' }, 'de'] },
},
// Conditionally visible field — only shown when needs_visa is true
{
id: 'visa_notes',
type: 'textarea',
label: { default: 'Visa / work permit details' },
visibleWhen: { '==': [{ var: 'answers.needs_visa' }, true] },
excludeWhen: { '!=': [{ var: 'answers.needs_visa' }, true] },
validation: {
requireWhen: { '==': [{ var: 'answers.needs_visa' }, true] },
},
},
],
// Multi-step flow with conditional navigation
flow: {
steps: [
{ id: 'personal', title: { default: 'Personal info' }, fields: ['first_name', 'country'] },
{
id: 'employment',
title: { default: 'Employment details' },
fields: ['department', 'start_date', 'needs_visa', 'visa_notes'],
},
],
navigation: { start: 'personal' },
},
};The engine evaluates this schema against the current form data to determine the runtime state of each field:
import { checkField, calculateData } from '@kotaio/adaptive-requirements-engine';
const formData = { first_name: 'Anna', country: 'us', department: 'engineering', start_date: '2099-01-01' };
// Compute derived values (needs_visa will be true because country !== 'de')
const computed = calculateData(requirements, formData);
// { needs_visa: true }
// Merge computed values into form data, then check a field
const data = { ...formData, ...computed };
const state = checkField(requirements, 'visa_notes', data);
state.isVisible; // true — country is 'us', so needs_visa is true
state.isRequired; // true — requireWhen rule matches
state.errors; // ['This field is required'] — no value provided yetAPI
Field state
checkField(requirements, fieldId, data, options?)
The primary function. Returns the complete runtime state for a single field: visibility, required status, validation errors, resolved options, computed value, and label. For non-computed fields, an explicit value from data wins; otherwise the engine falls back to the field's schema-level defaultValue.
import { checkField } from '@kotaio/adaptive-requirements-engine';
const state = checkField(requirements, 'date_of_birth', formData);
state.isVisible; // boolean — should this field be rendered?
state.isRequired; // boolean — is this field currently required?
state.isReadOnly; // boolean — should this field be read-only?
state.isExcluded; // boolean — should this field be excluded from submission?
state.errors; // string[] — validation error messages
state.value; // current field value (from data or computed)
state.options; // ResolvedFieldOption[] | undefined — resolved select/radio options
state.label; // string | undefined — resolved label text
state.field; // the field definition from the schemainitializeFormData(requirements)
Builds initial form data from schema-level field defaults. This is useful when your API already returns known answers as field.defaultValue and you want to seed form state directly from the schema.
import { initializeFormData } from '@kotaio/adaptive-requirements-engine';
const initialData = initializeFormData(requirements);
// { first_name: 'Ada', country: 'GB' }calculateData(requirements, data)
Returns computed field values only. Use this to update the form data after a field change.
import { calculateData } from '@kotaio/adaptive-requirements-engine';
const computed = calculateData(requirements, formData);
// { subtotal: 150, tax: 15, total: 165 }Cascading logic
When a field value changes, it can affect the visibility or exclusion of other fields. These functions iterate until stable.
clearHiddenFieldValues(requirements, data)
Clears values for fields whose visibleWhen rule evaluates to false. Iterates because hiding field A may hide field B.
import { clearHiddenFieldValues } from '@kotaio/adaptive-requirements-engine';
const cleaned = clearHiddenFieldValues(requirements, formData);applyExclusions(requirements, data)
Clears values for fields whose excludeWhen rule evaluates to true. Same cascading behavior as above.
import { applyExclusions } from '@kotaio/adaptive-requirements-engine';
const filtered = applyExclusions(requirements, formData);Options
resolveFieldOptions(field, datasets?, context?, labelResolver?)
Resolves a field's options — either from inline options or from a referenced dataset via optionsSource. Supports dynamic filtering against current form data.
import { resolveFieldOptions } from '@kotaio/adaptive-requirements-engine';
const options = resolveFieldOptions(field, requirements.datasets, { data: formData });
// [{ value: 'us', label: 'United States' }, { value: 'ca', label: 'Canada' }]Rule evaluation
runRule(rule, context)
Evaluates a JSON Logic expression against a data context. Used internally by the engine, but available for custom rule evaluation.
import { runRule } from '@kotaio/adaptive-requirements-engine';
const result = runRule({ '>=': [{ var: 'age' }, 18] }, { data: formData });
// true or falseLabels
resolveLabel(label, locale?)
Resolves a localized label to a plain string. Labels can be simple strings or objects with a default value and an optional i18n key.
import { resolveLabel } from '@kotaio/adaptive-requirements-engine';
resolveLabel('First Name'); // 'First Name'
resolveLabel({ default: 'First Name', key: 'fields.first_name' }); // 'First Name'Validation
runCustomValidators(value, validators, context, customValidators?)
Runs an array of validators against a field value. Returns an array of error message strings (empty if valid). Checks built-in validators first, then falls back to any custom validators you provide.
import { runCustomValidators } from '@kotaio/adaptive-requirements-engine';
const errors = runCustomValidators(
'1990-01-01',
[{ type: 'dob_not_in_future' }, { type: 'age_range', params: { min: 18, max: 100 } }],
{ data: formData },
);
// [] (valid) or ['Must be at least 18 years old']builtInValidators
A record of built-in validator functions:
| Validator | Params | Description |
| ------------------- | ------------ | ---------------------------------------------------- |
| age_range | min, max | Validates age calculated from a date is within range |
| dob_not_in_future | — | Date must not be in the future |
| date_after | date | Date must be after the specified date |
| date_before | date | Date must be before the specified date |
| spanish_tax_id | — | Validates Spanish NIF/NIE format |
| irish_pps | — | Validates Irish PPS number format |
| german_tax_id | — | Validates German Steuer-ID (11 digits) |
| file_type | accept | File extension/MIME type matching |
| file_size | maxSize | File size limit in bytes |
| file_count | maxFiles | Maximum number of files |
validateRequirementsObject(input)
Structural validation for a requirements schema. Returns a ValidationResult with either the validated object or an array of errors. Useful for verifying API responses or testing.
import { validateRequirementsObject } from '@kotaio/adaptive-requirements-engine';
const result = validateRequirementsObject(schema);
if (result.success) {
// result.data is the validated RequirementsObject
} else {
// result.errors is ValidationError[]
// each error has { path: string, message: string }
}validateDatasetItems(input)
Validates an array of dataset items. Same ValidationResult return shape.
Flow navigation
When a schema includes a flow, these functions handle step progression.
getInitialStepId(flow, options?)
Returns the starting step ID. When options.requirements and options.formData are provided, skips steps with no visible fields.
getNextStepId(flow, currentStepId, data, options?)
Returns the next step ID. Evaluates navigation rules first (for conditional step skipping), then falls back to sequential order. Skips steps with no visible fields.
getPreviousStepId(flow, currentStepId)
Returns the previous step ID (sequential only).
stepHasVisibleFields(requirements, stepId, data, options?)
Returns true if the given step has at least one visible field. Used to skip empty steps during navigation.
Adapter
createAdapter(requirements, mapping?, options?)
Creates a bundled adapter object that wraps the engine functions with an optional field ID mapping. Useful when your consumer's field naming conventions differ from the schema's.
import { createAdapter } from '@kotaio/adaptive-requirements-engine';
const adapter = createAdapter(requirements, {
fieldIdMap: { firstName: 'first_name', lastName: 'last_name' },
});
// Now use adapter.checkField, adapter.calculateData, etc.
// They automatically translate between your field IDs and the schema'sRecipes
Blocking states
Some flows need to halt online progression when a user gives a particular answer and direct them to another channel (e.g. a phone-based journey). For example, ONVZ (Netherlands health insurance) requires asking whether the applicant has previous insurance — if they answer "no", the application must stop and a human takes over.
This is fully expressible by composing two existing schema primitives — no new schema constructs are required:
- A validation rule on the triggering field whose predicate is true (= valid) when not blocked, and false (= blocked). Because validation rules use the convention
truthy = valid, falsy = error, you negate the block condition. - A conditionally-visible
noticefield withvariant: 'danger'carrying the rich message in itsdescription(required body text) and an optionalheadingtitle, with avisibleWhenmatching the blocking condition.
When the rule fails, the field carries an error → the step's aggregate validity (isStepValid / canGoNext exposed to renderStepNavigation) flips to false → the default Next button is marked aria-disabled and its click handler refuses to advance. When the user changes their answer back, the rule passes, the notice hides, and forward navigation re-opens. Reversibility is automatic.
{
id: 'previous_insurance',
type: 'radio',
label: { default: 'Have you had previous health insurance?' },
options: [
{ value: 'yes', label: { default: 'Yes' } },
{ value: 'no', label: { default: 'No' } },
],
validation: {
required: true,
rules: [
{
// Truthy = valid, falsy = blocked. Negate the block condition.
rule: { '!=': [{ var: 'previous_insurance' }, 'no'] },
message: "We can't complete this online — see message below.",
},
],
},
},
{
id: 'no_prev_insurance_block',
type: 'notice',
// `variant` is required ('info' | 'warning' | 'danger') and drives the renderer's
// visual treatment plus the accessible role chosen by the fallback ('alert' for
// 'danger', 'status' otherwise).
variant: 'danger',
// `description` is the body of the notice (required for notice fields). It is a
// `LocalizedLabel`, so you can pass a plain string or `{ default, key }` for i18n.
description: {
default: "Please call 020-XXX-XXXX and we'll continue with you over the phone.",
key: 'onvz.no_prev_insurance.body',
},
// `heading` is an optional title shown above the description.
// Notice fields use `heading` instead of `label` — the validator rejects `label`.
heading: { default: "We can't complete this application online" },
visibleWhen: { '==': [{ var: 'previous_insurance' }, 'no'] },
},
{
// Follow-up question only shown when the answer is "yes".
id: 'previous_insurer',
type: 'text',
label: { default: 'Who was your previous insurance with?' },
visibleWhen: { '==': [{ var: 'previous_insurance' }, 'yes'] },
excludeWhen: { '!=': [{ var: 'previous_insurance' }, 'yes'] },
validation: { required: true },
},Variants:
- Plain field error (no separate notice): drop the notice field and put the full call-to-action in the validation rule's
message. The renderer's existing field-error UI handles it. - Hide other questions on the step when blocked: add
visibleWhen: { '!=': [{ var: 'previous_insurance' }, 'no'] }to subsequent fields. Pure schema-side; no library changes. - Block based on multiple fields: use
and/orin the rule andvisibleWhen— JSON Logic can reference any field via{ var: 'other_field' }.
For the React rendering side (the single notice renderer that switches on variant, takeover layout, custom step navigation), see the adaptive-form package README.
IBAN validation
The iban_valid operation validates an IBAN against the ISO 13616 per-country structure (length + format) and the ISO 7064 mod-97-10 checksum. It tolerates user-pasted spaces (IE21 ANIB 0011 1111 11).
Migrate a country-agnostic regex rule to a real IBAN check by swapping the rule in place:
{
"id": "iban",
"type": "text",
"label": { "default": "IBAN" },
"validation": {
"required": true,
"rules": [
{
"rule": {
- "match": [{ "var": "iban" }, "^[A-Z]{2}[0-9]{2}[A-Za-z0-9]{11,30}$"]
+ "iban_valid": [{ "var": "iban" }]
},
"message": "Please enter a valid IBAN"
}
]
}
}Pin the field to a specific country (rejects e.g. an IE IBAN typed into a GB-only field). The country code is case-insensitive — "gb", "GB", and "Gb" all behave identically:
{ "iban_valid": [{ "var": "iban" }, "GB"] }JSON Logic reference
Schemas use JSON Logic expressions for conditional visibility, conditional validation, computed values, and dataset filtering. The engine evaluates these automatically — this reference is for understanding what schemas can express.
Variables
{ var: 'fieldName' } // Access a field's current value
{ var: 'data.fieldName' } // Explicit data access (equivalent)
{ var: 'answers.fieldName' } // Alias for data access
{ var: 'item.property' } // Access a dataset item property (used in filters)Operators
Comparison: ==, !=, >, <, >=, <=, in
Logical: and, or, !
Conditional: ?: (ternary), if (if-then-else)
Math: +, -, *, /, %, max, min
String: cat (concatenate), substr
Custom operations
The engine registers these additional operations:
| Operation | Description |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| { today: {} } | Current date as YYYY-MM-DD |
| { match: [value, pattern, flags?] } | Regex test (returns true/false) |
| { phone_valid: [value, countryCode?] } | Phone number validation via libphonenumber-js; E.164 required when countryCode omitted |
| { iban_valid: [value, countryCode?] } | IBAN validation via ibantools (per-country length, structure, and mod-97 checksum). Spaces are tolerated. countryCode is case-insensitive |
Key types
Key types exported for use in custom integrations:
| Type | Description |
| --------------------- | ---------------------------------------------------------------------------------------------------- |
| RequirementsObject | Top-level schema: fields, datasets, and optional flow |
| Field | Single field definition: id, type, label, validation, visibility rules, etc. |
| NoticeField | Narrowed shape for notices: type: 'notice', required variant + description, optional heading |
| NoticeVariant | Literal union: 'info' \| 'warning' \| 'danger' — the notice severity |
| NOTICE_VARIANTS | Runtime constant — readonly tuple of the three variant strings |
| FieldState | Runtime state for a field: visibility, errors, value, options |
| FormData | Record<string, FieldValue> — the current form data |
| FieldValue | string \| number \| boolean \| null \| undefined or array thereof |
| Rule | A JSON Logic expression |
| Dataset | A named list of items that fields can reference |
| Flow | Step definitions and optional navigation rules |
| FieldMapping | Field ID remapping configuration |
| EngineOptions | Options for custom validators, locale, and label resolution |
| ValidationResult | { success: true, data } \| { success: false, errors } |
| ValidationError | { path: string, message: string } |
| ResolvedFieldOption | { value: string \| boolean, label: string } |
License
Apache-2.0 — see LICENSE for details.
