Skip to content

Validator

A Validator<C> is the smallest unit of work in validup:

typescript
type ValidatorContext<C = unknown> = {
    key: string;            // expanded mount path inside the current container
    path: PropertyKey[];    // global mount path including parent containers
    value: unknown;         // the value to validate
    data: Record<string, any>; // input of the current container
    group?: string;
    context: C;
    signal?: AbortSignal;
};

type Validator<C = unknown> =
    (ctx: ValidatorContext<C>) => Promise<unknown> | unknown;

It receives the field's current value (plus the surrounding context), and either:

  • Returns a value — written to output[key]. Returning a transformed value is how you parse/coerce in the same pass.
  • Throws — converted to an Issue. Any thrown Error.message becomes the issue message; a thrown ValidupError contributes its .issues re-pathed under the current mount.

Writing a validator

typescript
import type { Validator } from 'validup';

const isPositiveInt: Validator = ({ value, key }) => {
    if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
        throw new Error(`${key} must be a positive integer.`);
    }
    return value;
};

Use it like any other mount target:

typescript
container.mount('age', isPositiveInt);
container.mount('count', { optional: true }, isPositiveInt);

Descriptor form: defineValidator

A bare function works, but for richer contracts use defineValidator({ run, ... }) — it returns a ValidatorDescriptor<C, Out> that mount() accepts interchangeably with a bare function. The wrapper attaches per-mount metadata the framework reads at run time. Today the only metadata field is sideEffect, the switch that opts a validator OUT of the result cache:

typescript
import { defineValidator } from 'validup';

// Default: cache-eligible — same (value, context, group) → reuse result.
const isPositiveInt = defineValidator({
    run: ({ value, key }) => {
        if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
            throw new Error(`${key} must be a positive integer.`);
        }
        return value;
    },
});

// Cross-field / network / stateful — re-run every time:
const isEmailUnique = defineValidator({
    sideEffect: true,
    run: async ({ value }) => {
        if (await api.isEmailTaken(value as string)) {
            throw new Error('Email is already taken');
        }
        return value;
    },
});

Why a descriptor instead of attaching properties on the function? Object-based metadata survives composition (wrapping the validator doesn't lose the flag), is visible at the type level, and stays symmetrical with the rest of the library (defineIssueItem / defineIssueGroup). Bare functions remain fully supported — mount('foo', fn) normalizes them to { run: fn } internally, with no behavior change. See Caching for the full opt-in story.

Transforming values

A validator can return a parsed value (different from value):

typescript
const toDate: Validator = ({ value, key }) => {
    const d = new Date(value as string);
    if (Number.isNaN(d.getTime())) {
        throw new Error(`${key} must be a valid ISO date.`);
    }
    return d; // Container output now contains a Date instead of a string
};

Sequential run reads sibling output if a previous mount has populated it (hasOwnProperty(output, key)), so a sanitize-then-validate chain on the same path works:

typescript
container.mount('name', sanitizeName); // returns trimmed string
container.mount('name', isString);     // sees the trimmed value

Note: parallel mode (opts.parallel: true) reads value from the input data only, skipping the sibling-output read. Use sequential mode for sanitize-then-validate chains.

Lazy / context-aware validators

The integration adapters (@validup/zod, @validup/standard-schema) accept either a schema or a function (ctx) => schema. The function form lets you build a per-call schema from ctx.group, ctx.context, or ctx.data. @validup/validator-js factories aren't lazy by default — they bind their options at factory-build time; wrap them in a closure if you need per-context configuration.

Every adapter returns a ValidatorDescriptor (interchangeable with a bare Validator at the mount site), so the cache integration is end-to-end. The schema adapters (@validup/zod, @validup/standard-schema) accept a { sideEffect: true } option on their createValidator(schema, options?) factory for opting out of caching per call:

typescript
import { createValidator } from '@validup/zod';

container.mount('email', createValidator(z.string().email()));                       // cached
container.mount('email', createValidator(zSchema, { sideEffect: true }));            // never cached

@validup/validator-js doesn't surface a uniform per-factory sideEffect option — every shipped factory is deterministic by construction and is cache-eligible by default. The one exception is equals(key, options?), which auto-stamps sideEffect: true when no expectedValue is provided (it reads ctx.data[key], which the cache snapshot doesn't capture); the generic createValidator(fn, { code, message, data?, sideEffect? }) is the one place where the option is exposed, for the rare case where the wrapped predicate captures external state.

For your own validators, the same pattern is just a closure:

typescript
const isUniqueEmail = (db: Db): Validator => async ({ value }) => {
    if (await db.users.findOne({ email: value })) {
        throw new Error('Email already in use.');
    }
    return value;
};

container.mount('email', isUniqueEmail(db));

If you'd rather thread context through the run rather than capture it in a closure, use the Container<T, C> second generic and read ctx.context:

typescript
const isUniqueEmail: Validator<{ db: Db }> = async ({ value, context }) => {
    if (await context.db.users.findOne({ email: value })) {
        throw new Error('Email already in use.');
    }
    return value;
};

const c = new Container<{ email: string }, { db: Db }>();
c.mount('email', isUniqueEmail);

await c.run({ email: 'peter@example.com' }, { context: { db } });

Sync vs async

Validators may be sync or async. run() always awaits; runSync() throws if the return value is thenable. Use sync validators for reactive UIs that should not flicker through a pending state.

Cancellation

If the run was started with { signal }, the validator receives the same signal in ctx.signal. Forward it to async work that supports cancellation:

typescript
const fetchProfile: Validator = async ({ value, signal }) => {
    const r = await fetch(`/profile/${value}`, { signal });
    return r.json();
};

Throwing an abort error mid-validator propagates verbatim — it is not folded into the issue list. See Cancellation.

Released under the Apache 2.0 License.