Skip to content

Validation ​

Besides the built-in validations, a changeset can be validated by Zod, by any other Standard Schema library (Valibot, ArkType, Yup), or by a plain function. The core package depends on none of them: it talks to every library through the Standard Schema interface.

ts
cs.validate(validator); //                       synchronous; returns a new changeset
await cs.validateAsync(validator); //            for validators that answer with a promise
cs.validateField("email", z.string().email()); // one field, only when it has a change
await cs.validateFieldAsync("email", z.string().email());

Each method returns a new changeset, like every other changeset method. A validator that finds problems adds errors; it never removes changes.

Choosing ​

  • Built-in validations (validateRequired, validateLength, …) for single-field rules with standard, translatable messages.
  • A Zod schema (or Valibot, ArkType, Yup) when you already have one, when the same definition is shared with a client, or when @egon-orm/zod can derive it for you.
  • A function for rules that need several fields, outside state, or a message of your own, without a library.

They mix freely: a changeset function can call validateRequired, then validate with a schema, then validate with a function.

With Zod ​

ts
const UserInput = z.object({
  email: z.string().email(),
  age: z.number().int().min(13).nullish(),
});

const cs = cast(User, params, ["email", "age"]).validate(UserInput);
cs.errors.email; // [{ message: "Invalid email address", validation: "schema", meta: { path: [] } }]

Each issue becomes a changeset error on the field named by the first segment of its path, with validation: "schema". The rest of the path is kept in meta.path (["theme"] for an issue at settings.theme). Path segments written as { key } objects, as some libraries report them, are unwrapped.

An issue with an empty path, or one whose path does not start with a field name, belongs to the record as a whole: it is stored under the key BASE ("$base"), with its whole path in meta.path.

ts
const checked = cs.validate(() => [{ path: [], message: "overlaps another talk" }]);
checked.errors[BASE]; // [{ message: "overlaps another talk", validation: "schema", meta: { path: [] } }]
checked.errorMessages(); // { $base: ["overlaps another talk"] }

Errors are appended after the errors the changeset already has, in the order the validator reported them. A field that already has an error (from cast, validateRequired or an earlier validation) gets no more: its issues are dropped, so each field shows one message, the first failure. cast(User, { email: "" }, ["email"]) reports can't be blank, and a schema that then finds no email adds nothing to it. The same holds for a field of a nested changeset. Issues on the record as a whole (BASE) are always kept.

Cross-field rules ​

A rule that compares fields reports on the field the user should fix:

ts
cs.validate(
  z
    .object({ startsAt: z.date(), endsAt: z.date() })
    .refine((v) => v.endsAt > v.startsAt, { path: ["endsAt"], message: "must be after start" }),
);

With a function ​

ts
cs.validate((values, { isChanged }) =>
  isChanged("endsAt") && values.endsAt <= values.startsAt
    ? [{ path: "endsAt", message: "must be after start" }]
    : [],
);

A function receives the values and a context, and returns a list of issues, or nothing:

ts
type ValidatorFn<T> = (
  values: Readonly<T>,
  ctx: {
    changes: Readonly<Partial<T>>;
    data: Readonly<T>;
    isChanged(field: keyof T): boolean;
    action: Action | null;
  },
) => ValidationIssue[] | undefined | Promise<ValidationIssue[] | undefined>;

type ValidationIssue = {
  path: PropertyKey | readonly PropertyKey[];
  message: string;
  meta?: Record<string, unknown>;
};

path is a field name, or a list whose first element is the field name. meta is merged into the error's meta, so it can fill {placeholders} in the message.

Return an array, even for one issue: [{ path: "email", message: "is taken" }]. An empty array and undefined (no return) mean no issues. Anything else is a bug in the function, and validate throws a TypeError that says what it got: null, a single issue without its array, a string, a number, any other object. With validateAsync, the same holds for what the promise resolves to, and the promise rejects with the TypeError. This includes a function typed as returning void, which TypeScript lets return a value: (v) => seen.push(v) returns a number, so write (v) => { seen.push(v); }.

ctx.data is the record before the changes: the loaded row, or, for a new record, an object of the field defaults (undefined for a field without one). It is never null. A default given as a function is called once per validation, so values and ctx.data hold the same default.

What the validator receives ​

A plain object of every field with the changes applied: the loaded row (or a new record's defaults), overlaid with the changes, in field order. The values are the converted ones (Date, numbers, enum strings), as cast produced them. Virtual fields are included, and so are redacted fields.

A field with no value (a new record's field that has neither a change nor a default) is left out of the object, not set to undefined, so that a schema's optional keys accept it and a required key reports it as missing.

Options ​

OptionDefaultEffect
scope"all""all" keeps every issue, except on a field that already has an error. "changed" keeps only issues on fields that have a change (and issues on the record as a whole).
applyfalseWhen true and the schema finds no issue, its output is written back as changes, so transforms such as .trim() take effect. Standard Schemas only; cannot be combined with only or scope: "changed".
onlyall fieldsGives the validator only these fields, and keeps only issues on them (and on the record as a whole).
ts
const Profile = z.object({ email: z.string().email(), name: z.string().min(2).nullable() });

cs.validate(Profile, { scope: "changed" }); // only issues on fields that changed
cs.validate(Profile, { only: ["name"] }); //  only the name: the object and the issues

Why scope: "all" is the default ​

A cross-field rule reports on one field, but is often broken by a change to another. In the example above, moving startsAt past endsAt reports on endsAt, which did not change. Keeping every issue makes sure that rule is never dropped.

Use "changed" when existing rows may hold data that would no longer pass a new rule, and that old data should not block unrelated updates: a user editing their name is then not told that the email they registered years ago is no longer accepted.

Transforms with apply ​

ts
const Normalized = z.object({ email: z.string().trim().toLowerCase() });

const cs = cast(User, { email: "  Ada@Example.COM " }, ["email"]).validate(Normalized, { apply: true });
cs.changes; // { email: "ada@example.com" }

For each key of the schema's output that is a field, the value is first cast with the field's type, as cast casts input. A value the type rejects (a transform that turned a string field's value into a number) becomes a cast error on that field and is not written. A value that differs from the field's current value (compared by the field's type, so an equal Date is not a change) is recorded as a change, as putChange would record it. Output keys that are not fields are ignored.

Nothing is applied when there are issues: a schema that reports an issue has no output. For that reason apply cannot be combined with only or scope: "changed", which drop issues after the schema has run; that combination throws, rather than leaving a valid changeset whose transform silently did not happen. To transform some fields only, validate with a schema of just those fields. A function validator has no output, so apply with a function throws too.

One field: validateField ​

ts
cs.validateField("email", z.string().email()); //   a schema for the value
cs.validateField("age", (age) => (age % 2 === 0 ? [] : [{ path: [], message: "must be even" }]));

validateField runs only when the field has a change, and not on a change to null or on a field that already has an error (like the built-in validations). The validator receives the changed value alone. Every issue goes on that field, its path in meta.path. A function given to validateField returns an array of issues or nothing, like one given to validate, and throws the same TypeError otherwise.

Async validation ​

A changeset is synchronous: validate throws validator returned a Promise; use validateAsync if the validator answers with a promise. Use validateAsync for a check that has to wait, such as a call to another service, and await it:

ts
const checked = await cs.validateAsync(async (v) =>
  (await isDisposable(v.email)) ? [{ path: "email", message: "is not allowed" }] : [],
);

Keeping the two apart means that the ordinary path never waits, and that an accidental async function in a synchronous chain is an error you see at once, not a validation that silently never ran. validateAsync accepts synchronous validators too.

A validator that throws is a bug in the validator: the exception propagates and is not turned into a validation error.

Other libraries ​

Any value with a Standard Schema ~standard property works, whatever library made it.

Valibot

ts
cs.validate(v.object({ email: v.pipe(v.string(), v.email()), age: v.nullish(v.number()) }));

ArkType

ts
cs.validate(type({ email: "string.email", "age?": "number.integer >= 13 | null" }));

Yup implements Standard Schema from version 1.7, but its validate always answers with a promise, so its schemas go to validateAsync:

ts
// Yup's Standard Schema always answers with a promise: use validateAsync.
await cs.validateAsync(yup.object({ email: yup.string().email().required() }));

To validate with Yup synchronously, wrap the schema in a function that calls validateSync and returns issues. The same approach works for any library without Standard Schema support:

ts
/** Yup's path text (`settings.theme`, `tags[0]`, `["a.b"]` for a key with a dot) as a list of keys. */
function yupPath(path: string | undefined): PropertyKey[] {
  const keys: PropertyKey[] = [];
  for (const [, quoted, index, plain] of (path ?? "").matchAll(
    /\["((?:[^"\\]|\\.)*)"\]|\[(\d+)\]|([^.[\]]+)/g,
  )) {
    keys.push(quoted ?? (index !== undefined ? Number(index) : (plain as string)));
  }
  return keys;
}

/** Runs a Yup schema synchronously, as a validator function. */
export function yupSync(schema: yup.AnyObjectSchema) {
  return (values: object): ValidationIssue[] => {
    try {
      schema.validateSync(values, { abortEarly: false });
      return [];
    } catch (error) {
      if (!(error instanceof yup.ValidationError)) throw error;
      const errors = error.inner.length > 0 ? error.inner : [error];
      return errors.map((e) => ({ path: yupPath(e.path), message: e.message }));
    }
  };
}
ts
cs.validate(yupSync(yup.object({ email: yup.string().email().required() })));

A function has no output, so apply is not available this way.

Deriving Zod schemas from a schema class ​

@egon-orm/zod builds a Zod schema from a schema class, so the shape is written once. It needs Zod 4.6.6 or later as a peer dependency.

ts
const UserZ = toZod(User); //                                 every field, as in a row
const CreateUser = toZod(User, { mode: "insert", pick: ["email", "name", "age"] }).extend({
  email: z.string().email(),
});
type CreateUser = z.infer<typeof CreateUser>; //             { email: string; name?: string | null; age?: number | null }

const draft: CreateUser = { email: "ada@example.com", name: null };
cast(User, draft, ["email", "name", "age"]).validate(CreateUser);
ModeShape
"row"Every field, exactly as in a row, virtual and redacted fields included. The default.
"insert"The fields an insert takes (no virtual fields). A field is optional when it is nullable, has a default, or is generated (by .autogenerate() or by the database).
"update"Every field, all optional.

pick and omit are typed and checked. An optional key may be absent but may not hold undefined (Zod's exactOptional).

Every field accepts exactly the values its type accepts, checked with the type's own cast: the members of an enum, a decimal's scale, a timestamp's precision, a UUID in lower case. The built-in scalar types (t.string(), t.integer(), t.bigint(), t.float(), t.boolean(), t.uuid(), t.binary(), the dates, times and timestamps) start from the matching Zod type (z.string(), z.int(), z.date(), …), so a value of the wrong kind gets Zod's own message. Enums, arrays, decimals with a precision, JSON and custom types are a z.custom() check, reported as is invalid. An embed is a nested object (a list of them for embedMany). null is accepted only for a .nullable() field.

The result is an ordinary Zod object: .extend() it to add rules, and pass it to validate. castWith(target, params, schema) is shorthand for cast with the schema's keys as the permitted list, followed by .validate(schema); every key of the schema must be a field:

ts
const SignUp = toZod(User, { mode: "insert", pick: ["email", "name"] }).extend({
  email: z.string().email(),
});

const cs = castWith(User, params, SignUp); // cast(User, params, ["email", "name"]).validate(SignUp)

A custom type becomes a check that accepts what the type's own cast accepts. To give it a proper Zod schema instead, add a zod hint to the type:

ts
const Slug = defineType<string>({
  name: "slug",
  db: { kind: "text" },
  cast: (input) => (isSlug(input) ? { success: true, data: input } : { success: false }),
});
// defineType keeps only the members it knows: add the hint to the type it returns.
const SlugType: FieldType<string, string> & ZodHint<string> = {
  ...Slug,
  zod: z.string().regex(/^[a-z0-9-]+$/),
};

class Article extends Schema("articles", {
  id: t.integer().primaryKey().dbGenerated(),
  slug: t.type(SlugType),
}) {}
toZod(Article).shape.slug; // the hint, not a check built from the type

Writing a reusable validator ​

A validator is a value, so a rule used by several schemas is a function that returns one. Type it with ValidatorFn<T> for the fields it needs:

ts
/** Rejects a range whose end is not after its start. Works with any schema that has the two fields. */
export function endsAfterStarts<T extends { startsAt: Date; endsAt: Date }>(): ValidatorFn<T> {
  return (values) =>
    values.endsAt > values.startsAt ? [] : [{ path: "endsAt", message: "must be after start" }];
}

export function talkChangeset(talk: Talk | typeof Talk, params: unknown) {
  return cast(talk, params, ["title", "startsAt", "endsAt"])
    .validateRequired(["title", "startsAt", "endsAt"])
    .validate(endsAfterStarts());
}

Good to know

  • Validation keeps every issue by default, except on a field that already has an error, which keeps its first message. A rule that reports on a field the user did not change is still reported (scope: "all"); opt in to scope: "changed" per call.
  • A validator function returns an array, or nothing. return { path, message }, return "is taken" or return null throws a TypeError; it is never read as "no issues".
  • validate is synchronous. A validator that returns a promise, such as any Yup schema, needs validateAsync.
  • Validators never change values unless you pass apply: true, and then only when they found no issue.
  • Errors are plain data. A schema error is { message, validation: "schema", meta: { path } }, serialisable like every other changeset error.

See also ​