Validation

Validators live in @sometic/validation and plug into @sometic/forms via register / form-level validators. Issues are structured (code, message, path?, params?) so UI, a11y, and server mapping share one model.

Installation

bash
pnpm add @sometic/validation @sometic/forms

Import

ts
import {
    required,
    email,
    minLength,
    maxLength,
    pattern,
    url,
    min,
    max,
    integer,
    oneOf,
    custom,
    pipe,
    all,
    any,
    when,
    refine,
    transform,
    createIssue,
    ok,
    fail,
    runValidators,
} from "@sometic/validation";

Issue model

ts
type ValidationIssue = {
    code: string;
    message: string;
    path?: string;
    params?: Record<string, unknown>;
};

createIssue("required", "Required", { path: "email" });
ok(); // { valid: true, issues: [] }
fail(issue); // valid iff issues empty

runValidators runs validators sequentially (awaiting async), attaches missing paths from context, and collects issues from completed validators (does not stop on first failure, use pipe for short-circuit).

Built-in factories

FactoryCodeEmpty / nullNotes
required(msg?)requiredfails null, undefined, "" (trim), []
minLength(n, msg?)minLengthok if no length
maxLength(n, msg?)maxLengthok if no length
pattern(regex, msg?)patternok for null/undefined/""
email(msg?)via patternsame as patternDefault "Invalid email"
url(msg?)urlok empty; uses URL
min(n, msg?)minok empty
max(n, msg?)maxok empty
integer(msg?)integerok empty
oneOf(options, msg?)oneOfok empty
custom(pred, msg?, code?)customfails when pred false

Important: optional validators (email, min, url, …) pass empty values. Pair them with required() when the field is mandatory.

Composition

HelperBehavior
pipe(...validators)Stop on first invalid
all(...validators)Run all; merge issues
any(...validators)First success wins; else merge failures
when(predicate, validator)Skip when predicate is false
refine(predicate, message, code?)Extra predicate (async-capable); default code refine
transform(map, validator?)Map value then optionally validate
syncOnly(validator)Marker helper for sync-only pipelines
ts
import { pipe, required, email, minLength } from "@sometic/validation";

form.register("email", {
    validators: [pipe(required(), email(), minLength(5))],
});

Form-level validators

ts
const form = createForm({
    defaultValues: { password: "", confirm: "" },
    validators: [
        refine(
            (value) => {
                const values = value as { password: string; confirm: string };
                return values.password === values.confirm;
            },
            "Passwords must match",
            "password-mismatch",
        ),
    ],
});

Form-level validators run during full validateForm() / submit (not when validating a path subset).

Schema adapter contract

No Zod/Yup runtime is bundled into forms. Prefer:

  1. Native field validators (required(), email(), …)
  2. Small first-party schemas via @sometic/validation/define + fromSchema
  3. Optional adapters: @sometic/validation-zod / @sometic/validation-yup
ts
import { defineSchema, fromSchema, object, string } from "@sometic/validation/define";

const userSchema = defineSchema(
    object({
        email: string({ email: true, nonempty: true }),
        name: string({ min: 2 }),
    }),
);

createForm({
    defaultValues: { email: "", name: "" },
    validators: [fromSchema(userSchema)],
});

Zod:

ts
import { z } from "zod";
import { createZodSchemaAdapter } from "@sometic/validation-zod";
import { fromSchema } from "@sometic/validation/schema";

createForm({
    defaultValues: { email: "" },
    validators: [fromSchema(createZodSchemaAdapter(z.object({ email: z.string().email() })))],
});

Custom adapters implement:

ts
type SchemaAdapter<T> = {
    parse(input: unknown): T;
    safeParse(input: unknown): SchemaSafeParseResult<T>;
    validateAsync?(input: unknown, opts?: { signal?: AbortSignal }): Promise<ValidationResult>;
};

assertSchemaAdapter(adapter);

Wire with fromSchema(adapter) as a form or field Validator.

Wiring to fields

ts
form.register("age", {
    validators: [required(), integer(), min(18)],
    validateOn: "onChange",
    transform: (value) => (value === "" ? value : Number(value)),
});

See Fields for trigger modes and Async validation for promises, debounce, and races.

Accessibility

Use formatIssueSummary + announceFormErrors from @sometic/forms/a11y after failed submit. Keep code stable for i18n maps.