Skip to content

Validation and conditions ​

validate, disabledCondition, and readOnlyCondition are app-supplied hooks on FieldDescription. @dynamic-field-kit/core includes a set of built-in helper validators as well as async validation functions (validateFieldsAsync).

ts
import {
  validators,
  validateFields,
  validateFieldsAsync,
} from '@dynamic-field-kit/core';

const fields: FieldDescription[] = [
  {
    name: 'email',
    type: 'text',
    // Built-in composed validator
    validate: validators.compose(
      validators.required('Email is required'),
      validators.email('Must be a valid email address'),
    ),
    readOnlyCondition: (data, rootData) => (rootData ?? data).frozen === true,
  },
  {
    name: 'username',
    type: 'text',
    // Async validation (e.g. checking availability via API)
    validate: async (value) => {
      if (!value) return 'Username required';
      const available = await checkUsername(String(value));
      return available ? undefined : 'Username already taken';
    },
  },
  {
    name: 'city',
    type: 'select',
    // Dynamic options callback dependent on current form data
    options: (data) =>
      data.country === 'VN' ? ['Hanoi', 'HCM'] : ['NY', 'LA'],
    disabledCondition: (data) => !data.country,
  },
];

Built-in Validators Utility (validators) ​

validators provides common validation helpers:

  • validators.required(message?) - Enforces non-empty value
  • validators.email(message?) - Enforces valid email pattern
  • validators.minLength(min, message?) - Enforces minimum string/array length
  • validators.maxLength(max, message?) - Enforces maximum string/array length
  • validators.min(minVal, message?) - Enforces minimum numeric value
  • validators.max(maxVal, message?) - Enforces maximum numeric value
  • validators.pattern(regex, message?) - Enforces regex pattern match
  • validators.matches(otherFieldName, message?) - Enforces equality with another field, for confirm-password / confirm-email. Skips empty values so required owns that message, and compares with Object.is so two NaNs match
  • validators.compose(...fns) - Combines multiple validator functions into one

validateFields(fields, data, rootData?, context?) returns { valid, errors, complete, status }, recursing into repeatable groups (keys like contacts[0].email) and skipping fields that are hidden by appearCondition or disabled. Adapters call validateField / resolveDisabled / resolveReadOnly / resolveOptions per field to surface error, disabled, readOnly and the resolved options to renderers reactively.

Validation messages ​

Built-in validators resolve their message when they run, not when the field description is built, so a catalog set once for a form reaches all of them:

ts
import {
  createMessageResolver,
  setDefaultMessages,
} from '@dynamic-field-kit/core';

// Per form, through an adapter:
useDynamicForm({ fields, messages: { required: 'Bắt buộc' } });

// Or process-wide, for direct validateFields callers:
setDefaultMessages({ required: 'Bắt buộc' });

// Or built by hand and passed as the validation context:
validateFields(fields, data, undefined, {
  t: createMessageResolver({ required: 'Bắt buộc' }),
});

Precedence is: a message passed straight to the validator, then the form's catalog, then the process-wide one, then the validator's English default.

KeyParamsEnglish default
required—Field is required
email—Invalid email address
minLength{min}Minimum length is
maxLength{max}Maximum length is
min{min}Minimum value is
max{max}Maximum value is
pattern—Invalid format
matches{other}Must match

No locale bundles ship with this package. Supply your own catalog. A placeholder with no matching param is left verbatim rather than replaced with undefined, so a typo surfaces as a visible {unit}.

ValidationContext — already validate's fourth argument, carrying signal — gains an optional t, so a hand-written validator can translate its own messages the same way.

Async options ​

options takes a static array, a synchronous (data, rootData) => Options[], or a loader returning a promise. It is one signature, not a union: a union of two function types defeats TypeScript's contextual inference, which would make every existing options: (data) => … an implicit-any error.

ts
{
  name: 'city',
  type: 'select',
  options: async (data, _rootData, ctx) =>
    fetch(`/api/cities?country=${data.country}`, { signal: ctx?.signal })
      .then((r) => r.json()),
  optionsDeps: (data) => [data.country],
  debounceMs: 200,
}
PropertyEffect
optionsDepsValues a reload depends on, compared shallowly with Object.is. Default [] — fetch once
optionsMode'async' for a loader returning a promise without the async keyword
debounceMsCollapses rapid reloads into one fetch. Async options only

Renderers receive optionsStatus ('idle' | 'loading' | 'ready' | 'error'), optionsError, and onOptionsQuery(query) for search-remote fields whose query the form data never sees.

createOptionsLoader(field, onChange) is the framework-agnostic engine the adapters wrap: it debounces, aborts a superseded request through ctx.signal, and discards a response that lands out of order, so the list always reflects the newest request rather than the last to arrive.

Native async functions are detected automatically. A loader wrapped in a memoiser, a spy or a transpiler helper is not — constructor.name stops being 'AsyncFunction'. Declare optionsMode: 'async' for those; without it the promise is dropped and a development warning says so, rather than the renderer receiving a pending promise as its option list.

Reading a ValidationResult ​

ts
interface ValidationResult {
  valid: boolean;
  errors: Record<string, string[]>;
  /** Fields whose validator could not run synchronously. Omitted when empty. */
  pending?: string[];
  /** True only when every applicable validator finished in this pass. */
  complete: boolean;
  status: 'valid' | 'invalid' | 'pending';
}

Read status. valid on its own cannot tell "nothing is wrong" from "nothing is wrong yet": a form whose only remaining rule is a remote uniqueness check reports valid: true while that check has not run, which reads as a green light. status is 'pending' exactly when something is still unanswered, and complete says whether every applicable validator finished.

Sync vs async validation ​

validateField and validateFields are synchronous. An async validator is never invoked on that path - it is listed in result.pending instead, and the result comes back status: 'pending'. Async validators must go through the async pair for a final answer:

ts
import {
  validateField,
  validateFieldAsync,
  validateFields,
  validateFieldsAsync,
} from '@dynamic-field-kit/core';

// One field. Both always return string[] (empty when valid).
const errors = validateField(field, value, data, rootData); // string[]
const errorsAsync = await validateFieldAsync(field, value, data, rootData);

// A whole schema. The sync result may also include `pending` field names.
const result = validateFields(fields, data); // sync hooks only
const resultAsync = await validateFieldsAsync(fields, data); // awaits each hook

validateFieldsAsync starts independent validators in parallel rather than awaiting them one after another, so a form with several remote rules costs the slowest one, not their sum.

The framework form helpers keep live validation synchronous, but their submit handlers automatically run an async-capable validation pass. They also expose validateAsync() for checks that must finish before submit.

Declaring an async validator ​

Detection covers a native async function. A function that returns a Promise without the async keyword is indistinguishable from a sync one until it has been called, so declare it:

ts
const field: FieldDescription = {
  name: 'username',
  type: 'text',
  validationMode: 'async',
  validate: (value) => checkAvailability(value), // returns a Promise
};

With validationMode: 'async' the sync pass never invokes the validator - no wasted request per keystroke - and the dev-mode warning about a field live validation cannot check stays quiet, because you have said you know.

Cancelling a run ​

validateFieldsAsync(fields, data, rootData?, options?) takes a ValidationContext and hands its AbortSignal to every validator as a fourth argument:

ts
const controller = new AbortController();
const result = await validateFieldsAsync(fields, data, data, {
  signal: controller.signal,
});

const field: FieldDescription = {
  name: 'username',
  type: 'text',
  validationMode: 'async',
  validate: (value, _data, _rootData, context) =>
    fetch(`/api/available?u=${value}`, { signal: context?.signal }).then((r) =>
      r.ok ? undefined : 'Already taken',
    ),
};

Once the signal aborts, remaining validators are skipped and the result comes back complete: false / status: 'pending' - a partial answer, never a false "valid". A validator that honours the signal by rejecting with an AbortError is treated the same way, so an aborted run does not reject the caller. Any other error still propagates.

The framework form helpers create and abort these controllers for you: typing cancels the live validation run in flight so a stale result cannot overwrite a newer one.

resolveOptions(field, data, rootData?) returns Properties[] | undefined, calling field.options when it is a callback and passing it through when it is a static array.

Schema adapters (Zod, Yup, Valibot / Standard Schema) ​

Attach a schema to a field's validate hook. By default the schema is treated as an object schema describing the whole form, and a field name selects which issues to surface:

ts
import {
  zodValidator,
  yupValidator,
  valibotValidator,
} from '@dynamic-field-kit/core';

const schema = z.object({ email: z.string().email() });

const fields: FieldDescription[] = [
  { name: 'email', type: 'text', validate: zodValidator(schema, 'email') },
];

For a scalar schema covering a single value, say so explicitly:

ts
validate: zodValidator(z.string().email(), { target: 'field' });

Both accept SchemaValidatorOptions — { field?: string; target?: 'form' | 'field' }. valibotValidator is an alias of standardSchemaValidator, which handles any Standard Schema object (including Zod's ~standard).

Adapters parse synchronously, so the result works with the synchronous validateFields (and therefore with the framework form hooks). A schema with async refinements or async .test() rules cannot be parsed synchronously — those return a Promise, so validate through validateFieldsAsync.

Released under the MIT License.