ORM includes an async, typed validator with Laravel-style rule chains and no string DSL.
import { Validator, rule } from "@rekkr/orm/validation";
const validated = await Validator.make(input, {
email: rule().required().string().email().unique("users", "email"),
age: rule().required().integer().min(18),
password: rule().required().string().min(8).confirmed(),
role: rule().in(["admin", "member"] as const).default("member"),
}).validate();Fields are optional by default. Non-presence rules only run when the field is present:
await Validator.make({}, {
email: rule().email(), // passes when missing
}).passes();
await Validator.make({}, {
email: rule().required().email(), // fails when missing
}).passes();Validator.make(...).validate() returns the coerced, typed output or throws ValidationError with an error bag:
try {
await Validator.make(input, {
email: rule().required().email(),
}).validate();
} catch (error) {
if (error instanceof ValidationError) {
console.log(error.errors.email);
}
}There are two validation shapes:
// instance validator for object payloads
const validator = Validator.make(data, schema);
// Standard Schema-style object or root schema
const schema = Validator.schema({...});
const root = Validator.required().string();Instance validator:
const validator = Validator.make(data, schema);
await validator.validate(); // throws ValidationError on failure
await validator.validated(); // alias
await validator.parse(); // alias of validate()
await validator.safeParse(); // { success, output? , issues?, input? }
await validator.passes(); // boolean
await validator.fails(); // boolean
await validator.errors(); // Record<string, string[]>
validator.stopOnFirstFailure(); // stop after the first failed field
validator.collectAllErrors(); // collect every failed rule per fieldThe instance/object schema path accepts plain objects, FormData, URLSearchParams, and Request bodies. Request values are parsed as JSON when the content type is JSON, otherwise as form data.
Empty string values from plain objects, FormData, and URLSearchParams are treated as omitted fields, which keeps optional form inputs from failing format rules like email().
Root schema:
const username = await Validator.parse(Validator.required().string().min(3), input);
const result = await Validator.safeParse(Validator.required().string().min(3), input);
if (!result.success) {
console.log(result.issues);
console.log(result.input); // original input value (typed)
}Validator.required() starts a root schema with presence required. Validator.rule() starts the same builder without pre-applying required().
Standard Schema object schema:
const schema = Validator.schema({
name: rule().required().string(),
role: rule().in(["admin", "member"] as const).default("member"),
});
const validated = await Validator.parse(schema, { name: "Ada" }); // raw output
const result = await schema.safeParse({ name: "Ada" }); // { success, output?, issues?, input? }
if (!result.success) {
console.log(result.issues);
console.log(result.input); // original input object (typed)
console.log(Validator.flatten(result.issues)); // Record<string, string[]>
}Validator.schema(...) returns a Standard Schema-compatible object with ~standard, plus parse() / validate() / safeParse() / messages() methods. parse() returns raw validated output. validate() and safeParse() return { success, output, issues, input } where input is present on failures, which is convenient for SvelteKit wrappers and other Standard Schema consumers.
Validator.validate(...) and schema.validate(...) are aliases of safeParse().
Use Validator.flatten(issues) when you want a flat bag shape
(Record<string, string[]>) from the Standard Schema issues array.
Like the instance validator, Validator.schema(...) accepts plain objects, FormData, URLSearchParams, and Request inputs.
Schema-level messages() works the same way as the instance validator:
const schema = Validator.schema({
name: rule().required().string(),
}).messages({
name: "Name is required.",
"name.required": "Name is required.",
});Use the root schema helpers for single-argument command() / query() handlers, and Validator.schema(...) for form() handlers:
import { command, query, form } from "$app/server";
import { Validator, rule } from "@rekkr/orm/validation";
export const updateLikes = command(Validator.required().string(), async (id) => {
// id: string
});
export const getBranch = query(Validator.required().string(), async (id) => {
// id: string
});
export const createBranch = form(
Validator.schema({
name: rule().required().string(),
code: rule().required().string().unique("branches"),
is_active: rule().boolean(),
}),
async (data, issue) => {
// data is validated and typed
// issue is SvelteKit's form issue helper
},
);For update forms, read the current id from the payload and use ignoreField():
export const updateBranch = form(
Validator.schema({
id: rule().required().string(),
name: rule().required().string(),
code: rule().required().string().unique("branches").ignoreField("id"),
}),
async (data) => {
// code uniqueness ignores the id field from the same payload
},
);SvelteKit actions example:
import * as db from "$lib/server/db";
import { fail, type Actions } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { Validator, rule } from "@rekkr/orm/validation";
const loginSchema = Validator.schema({
email: rule().required().string().email(),
password: rule().required().string().min(8),
});
const profileSchema = Validator.schema({
id: rule().required().string(),
display_name: rule().required().string().min(2),
email: rule().required().string().email().unique("users", "email").ignoreField("id"),
});
export const load: PageServerLoad = async ({ cookies }) => {
const user = await db.getUserFromSession(cookies.get("sessionid"));
return { user };
};
export const actions = {
login: async ({ cookies, request }) => {
const result = await loginSchema.safeParse(await request.formData());
if (!result.success) {
return fail(400, { errors: result.issues });
}
const user = await db.getUser(result.output.email);
if (!user) {
return fail(401, { errors: { email: [{ message: "Invalid credentials." }] } });
}
cookies.set("sessionid", await db.createSession(user), { path: "/" });
return { success: true };
},
updateProfile: async ({ request }) => {
const result = await profileSchema.safeParse(await request.formData());
if (!result.success) {
return fail(400, { errors: result.issues });
}
await db.updateUser(result.output.id, {
display_name: result.output.display_name,
email: result.output.email,
});
return { success: true };
},
} satisfies Actions;In SvelteKit actions, safeParse() is the most direct fit because it gives you a success flag plus collected issues without throwing. Use parse() when you want the validated payload and are happy to let failures throw.
safeParse() failure results include input, so you can return both errors and the attempted payload:
const result = await profileSchema.safeParse(await request.formData());
if (!result.success) {
return fail(422, {
issues: result.issues,
input: result.input,
});
}To inspect errors without throwing, keep the validator instance and call fails() / errors():
const validator = Validator.make(input, {
email: rule().required().email(),
password: rule().required().string().min(8),
});
if (await validator.fails()) {
const errors = await validator.errors();
console.log(errors.email);
}Use messages() to override default messages. Keys can target a whole field or a specific rule:
await Validator.make(input, {
email: rule().required().email(),
})
.messages({
email: "Enter a valid email address.",
"email.required": "Email is required.",
})
.validate();Nested array paths support exact and wildcard message keys. Resolution order is:
- exact field + rule, like
items.0.sku.required - exact field, like
items.0.sku - wildcard field + rule, like
items.*.sku.required - wildcard field, like
items.*.sku - the rule's default message
await Validator.make(input, {
"items.*.sku": rule().required().distinct(),
"items.*.qty": rule().integer().min(1),
})
.messages({
"items.0.sku.required": "The first SKU is required.",
"items.*.sku.distinct": "Each SKU must be unique.",
"items.*.qty.integer": "Each quantity must be a number.",
})
.validate();Use stopOnFirstFailure() when you only need the first validation error for the entire payload:
const errors = await Validator.make(input, {
email: rule().required().email().unique("users", "email"),
name: rule().required().string(),
})
.stopOnFirstFailure()
.errors();If email fails, name is not validated. Without stopOnFirstFailure(), validation still stops after the first failed rule per field, but continues validating the rest of the payload.
Use collectAllErrors() when you want every rule failure for each field:
const errors = await Validator.make(input, {
email: rule().required().email(),
password: rule().string().min(8),
})
.collectAllErrors()
.errors();With collectAllErrors(), an empty email can return both the required and email messages for that field. Rules that intentionally skip or exclude a field, such as nullable(), sometimes(), and excludeIf(), still short-circuit that field.
Presence:
rule().required()
rule().nullable()
rule().sometimes()
rule().requiredIf("type", "business")
rule().requiredIf("type", ["test", "test2"])
rule().requiredUnless("type", "personal")
rule().requiredUnless("type", ["draft", "archived"])
rule().requiredWith("email")
rule().requiredWithAll("first", "last")
rule().requiredWithout("email")
rule().requiredWithoutAll("email", "phone")
rule().filled()
rule().accepted()
rule().acceptedIf("type", "terms")
rule().acceptedIf("type", ["terms", "checkout"])
rule().declined()
rule().declinedIf("type", "opt_out")
rule().declinedIf("type", ["opt_out", "unsubscribe"])
rule().present()
rule().presentIf("type", "business")
rule().presentIf("type", ["business", "enterprise"])
rule().presentUnless("type", "personal")
rule().presentUnless("type", ["personal", "guest"])
rule().presentWith("name")
rule().presentWithAll("first", "last")
rule().missing()
rule().missingIf("type", "person")
rule().missingIf("type", ["person", "guest"])
rule().missingUnless("type", "admin")
rule().missingUnless("type", ["admin", "owner"])
rule().missingWith("token")
rule().missingWithAll("token", "secret")
rule().prohibited()
rule().prohibitedIf("role", "member")
rule().prohibitedIf("role", ["member", "guest"])
rule().prohibitedUnless("role", "admin")
rule().prohibitedUnless("role", ["admin", "owner"])
rule().prohibits("password")
rule().exclude()
rule().excludeIf("type", "person")
rule().excludeIf("type", ["person", "guest"])
rule().excludeUnless("type", "business")
rule().excludeUnless("type", ["business", "enterprise"])
rule().excludeWith("token")
rule().excludeWithout("token")Presence rules decide whether a field must exist, may be omitted, or should be removed from the validated output.
| Rule | Meaning |
|---|---|
required() |
Field must be present and not undefined, null, empty string, or empty array. |
nullable() |
null / undefined is allowed and skips the rest of this field's rules. |
sometimes() |
If the field is missing, skip validation for it. If present, validate normally. |
filled() |
If the field is present, it must not be empty. Missing is allowed. |
requiredIf(field, valueOrValues) |
Required when another field equals the given value, or any value in the given array. |
requiredUnless(field, valueOrValues) |
Required unless another field equals the given value, or any value in the given array. |
requiredWith(field) |
Required when another field is present and not empty. |
requiredWithAll(...fields) |
Required when all listed fields are present and not empty. |
requiredWithout(...fields) |
Required when any listed field is missing or empty. |
requiredWithoutAll(...fields) |
Required when all listed fields are missing or empty. |
accepted() |
Value must be an accepted truthy consent value: true, "yes", "on", 1, or "1". |
acceptedIf(field, valueOrValues) |
Must be accepted only when another field equals the given value, or any value in the given array. |
declined() |
Value must be a declined consent value: false, "no", "off", 0, or "0". |
declinedIf(field, valueOrValues) |
Must be declined only when another field equals the given value, or any value in the given array. |
present() |
Key must exist in the input, but the value may be empty. |
presentIf(field, valueOrValues) |
Key must exist when another field equals the given value, or any value in the given array. |
presentUnless(field, valueOrValues) |
Key must exist unless another field equals the given value, or any value in the given array. |
presentWith(...fields) |
Key must exist when any listed field is present. |
presentWithAll(...fields) |
Key must exist when all listed fields are present. |
missing() |
Key must not exist in the input. |
missingIf(field, valueOrValues) |
Key must be missing when another field equals the given value, or any value in the given array. |
missingUnless(field, valueOrValues) |
Key must be missing unless another field equals the given value, or any value in the given array. |
missingWith(...fields) |
Key must be missing when any listed field is present. |
missingWithAll(...fields) |
Key must be missing when all listed fields are present. |
prohibited() |
Field must be missing or empty. Unlike missing(), an empty value is allowed. |
prohibitedIf(field, valueOrValues) |
Field must be missing or empty when another field equals the given value, or any value in the given array. |
prohibitedUnless(field, valueOrValues) |
Field must be missing or empty unless another field equals the given value, or any value in the given array. |
prohibits(...fields) |
If this field is present and not empty, the listed fields must be missing or empty. |
exclude() |
Remove this field from validated output. |
excludeIf(field, valueOrValues) |
Remove this field from output when another field equals the given value, or any value in the given array. |
excludeUnless(field, valueOrValues) |
Remove this field from output unless another field equals the given value, or any value in the given array. |
excludeWith(...fields) |
Remove this field when any listed field is present. |
excludeWithout(...fields) |
Remove this field when any listed field is missing or empty. |
Example: make company details conditional without a separate form request object:
const validated = await Validator.make(input, {
account_type: rule().required().in(["person", "company"] as const),
company_name: rule().requiredIf("account_type", "company").string(),
tax_id: rule().requiredWith("company_name").string(),
nickname: rule().excludeIf("account_type", "company").string(),
admin_notes: rule().prohibitedUnless("role", "admin"),
terms: rule().accepted(),
}).validate();Conditional rules accept either a single expected value or an array of expected values:
await Validator.make(input, {
name: rule().requiredIf("type", ["test", "test2"]).string(),
notes: rule().requiredUnless("status", ["draft", "archived"]).string(),
debug: rule().excludeIf("visibility", ["private", "internal"]),
}).validate();Type and coercion:
rule().string()
rule().integer()
rule().number()
rule().numeric()
rule().decimal(2)
rule().digits(4)
rule().digitsBetween(4, 6)
rule().multipleOf(2)
rule().boolean()
rule().array()
rule().array(["name", "role"])
rule().list()
rule().contains(["admin"])
rule().doesntContain(["root"])
rule().distinct()
rule().requiredArrayKeys("name", "role")
rule().date()
rule().dateFormat("Y-m-d")
rule().dateEquals("starts_at")
rule().after("starts_at")
rule().afterOrEqual("starts_at")
rule().before("ends_at")
rule().beforeOrEqual("ends_at")
rule().timezone()integer(), number(), numeric(), boolean(), and date() coerce common string inputs before later rules run. For example, rule().integer().min(18) accepts "18" and returns 18 in the validated output.
Array-oriented rules:
| Rule | Meaning |
|---|---|
array() |
Value must be an array. |
array(["name", "role"]) |
Object/array keys must be limited to the listed keys. |
list() |
Value must be a JavaScript array. |
contains([...]) |
Array must contain every listed value. |
doesntContain([...]) |
Array must not contain any listed value. |
distinct() |
Wildcard values such as items.*.sku must be unique. |
requiredArrayKeys(...keys) |
Object/array must contain the listed keys. |
Date comparison rules can compare against literal dates or another input field:
await Validator.make(input, {
starts_at: rule().required().date(),
ends_at: rule().required().date().afterOrEqual("starts_at"),
timezone: rule().timezone(),
}).validate();Size:
rule().min(3)
rule().max(255)
rule().between(1, 10)
rule().size(2)
rule().gt(18)
rule().gte("min_age")
rule().lt(100)
rule().lte("max_age")min(), max(), between(), and size() are type-sensitive:
- on
stringthey check character length - on
arraythey check item count - on
number/integerthey check numeric value
gt, gte, lt, and lte can compare to a literal number or to the size/value of another field.
Format and membership:
rule().email()
rule().url()
rule().uuid()
rule().regex(/^[A-Z]+-\d+$/)
rule().alpha()
rule().alphaNum()
rule().alphaDash()
rule().ascii()
rule().startsWith("INV-")
rule().endsWith(".com")
rule().doesntStartWith("tmp")
rule().doesntEndWith(".invalid")
rule().lowercaseOnly()
rule().uppercase()
rule().macAddress()
rule().ip()
rule().ipv4()
rule().ipv6()
rule().activeUrl()
rule().ulid()
rule().hexColor()
rule().phMobile()
rule().in(["admin", "member"] as const)
rule().notIn(["root"])Format rules are usually self-contained string checks. lowercaseOnly() validates that a string is already lowercase; lowercase() in the transform section converts the value to lowercase.
phMobile() normalizes Philippine mobile input first. It accepts local forms like 9171234567 and 09171234567, converts them to +639171234567, then validates the final shape.
Cross-field:
rule().confirmed() // password_confirmation
rule().same("email")
rule().different("old")confirmed() checks the conventional confirmation field. For password, it compares against password_confirmation. Use same() and different() for explicit field-to-field comparisons.
Files and higher-level rules:
rule().file()
rule().image()
rule().mimes("jpg", "png")
rule().mimeTypes("image/png")
rule().extensions("png")
rule().dimensions({ minWidth: 100, ratio: 1 })
rule().enum(Status)
rule().anyOf(rule().email(), rule().uuid())
rule().can((value, ctx) => isAllowed(value, ctx))
rule().password((p) => p.min(12).letters().mixedCase().numbers().symbols())File and image rules validate Bun/Web File / Blob-like objects with name, type, and size properties. dimensions() checks width / height when those properties are available.
Higher-level rules:
| Rule | Meaning |
|---|---|
enum(Status) |
Value must be one of the runtime enum/object values. Supports TypeScript enums and as const objects, including boolean literals. |
anyOf(ruleA, ruleB) |
Value may pass any one of several rule chains. |
can(callback) |
Custom authorization-style boolean check. |
custom(name, callback, message?) |
Inline custom validation rule with a named message key. |
use(ruleObject) |
Reusable custom rule object implementing RuleContract. |
password(...) |
Password builder with min, letters, mixedCase, numbers, and symbols. |
object(shape, mode?) |
Validate a fixed-shape nested object — each key gets its own RuleBuilder. Unknown keys are stripped ("strip" default); pass "passthrough" to keep them. See object / record / unknown. |
record(value) / record(key, value) |
Validate a dictionary — arbitrary keys, single value schema. Optional first arg constrains keys (valibot-style). |
unknown() |
Accept any value when present. Useful for intentionally-open JSON fields. |
Use custom() for one-off checks that stay close to the schema. Use use() when you want a reusable rule object, usually because the logic needs a class or will be shared across schemas.
enum Status {
Active = "active",
Paused = "paused",
}
const Visibility = {
open: false,
closed: true,
} as const;
await Validator.make(input, {
status: rule().enum(Status),
visibility: rule().enum(Visibility),
destination: rule().anyOf(rule().email(), rule().url()),
code: rule().custom(
"starts_with_bun",
(value) => typeof value === "string" && value.startsWith("BUN-"),
"The :attribute field must start with BUN-.",
),
password: rule().password((p) => p.min(12).mixedCase().numbers().symbols()),
}).validate();Context is available in both custom() and use() rules. The first example below uses an inline callback, and the second uses a reusable class. The inline version can compare values from the input:
await Validator.make(input, {
nickname: rule().string().custom(
"self_edit_only",
(value, ctx) => {
if (!value) return true;
return ctx.get("user_id") === ctx.get("current_user_id");
},
"Nickname changes are only allowed on your own profile.",
),
}).validate();Reusable custom rules implement RuleContract. This version reads from context as well:
import type { RuleContract, ValidationContext } from "@rekkr/orm/validation";
class CompanyEmailRule implements RuleContract {
name = "company_email";
validate(value: unknown) {
return typeof value === "string" && value.endsWith("@example.com");
}
message(ctx: ValidationContext) {
return `The ${ctx.attribute} field must end with @example.com.`;
}
}
await Validator.make(input, {
email: rule().string().use(new CompanyEmailRule()),
}).validate();This class-based rule compares two fields through ValidationContext:
import type { RuleContract, ValidationContext } from "@rekkr/orm/validation";
class MustMatchFieldRule implements RuleContract {
name = "must_match_field";
constructor(private otherField: string) {}
validate(value: unknown, ctx: ValidationContext) {
return value === ctx.get(this.otherField);
}
message(ctx: ValidationContext) {
return `The ${ctx.attribute} field must match ${this.otherField}.`;
}
}
await Validator.make(input, {
password_confirmation: rule().string().use(new MustMatchFieldRule("password")),
}).validate();Database-aware rules:
rule().unique("users", "email")
rule().unique("users", "email").ignore(user.id)
rule().unique("users", "email").ignoreField("id")
rule().unique("users", "email").ignore(user.uuid, "uuid")
rule().exists("roles", "id")
rule().unique("users", "email").where("tenant_id", tenantId)
rule().exists("roles", "id").whereNull("deleted_at")
rule().unique("users", "email").withoutTrashed()DB rules use TenantContext.current()?.connection first, then the default connection, so validation inside DB.tenant() is tenant-aware.
where, whereNot, whereNull, whereNotNull, and withoutTrashed() scope unique() / exists() checks:
await Validator.make(input, {
email: rule()
.required()
.email()
.unique("users", "email")
.ignoreField("id")
.where("tenant_id", tenantId)
.withoutTrashed(),
role_id: rule().exists("roles", "id").whereNull("deleted_at"),
}).validate();Use ignore(id) when the current row id is already available in scope. Use ignoreField("id") when the ignore value should come from another field in the same input payload, which is common for update forms.
Transforms:
rule().trim()
rule().lowercase()
rule().default("member")
rule().when(isAdmin, (r) => r.required())
rule().when((ctx) => ctx.get("field1") && ctx.get("field2"), (r) => r.required())
rule().unless(isDraft, (r) => r.required())default() is applied before validation, even if it appears later in the chain.
when() and unless() also accept a (ctx) => ... predicate when the condition depends on other fields in the same payload.
Dotted paths and * wildcards are supported:
await Validator.make(input, {
"profile.email": rule().email(),
"items.*.sku": rule().required().distinct(),
}).validate();Three native shape rules for nested data. All compose; all surface child errors with composed paths (meta.email, tags.primary, order.customer.email).
| Rule | Use when |
|---|---|
object(shape, mode?) |
Keys are fixed and known — each gets its own builder. Unknown keys are stripped by default; pass "passthrough" to keep them. |
record(value) / record(key, value) |
Keys are arbitrary at runtime, all values share one schema. One-arg form (zod-style) takes the value rule; two-arg form (valibot-style) takes (keyRule, valueRule). |
unknown() |
Field shape is intentionally open. Accepts anything (subject to required/nullable/sometimes). |
// fixed-shape object — typed per key, unknown keys stripped
const order = Validator.schema({
id: rule().integer(),
customer: rule().object({
name: rule().string(),
email: rule().string().email(),
}),
});
// inferred output: { id: number; customer: { name: string; email: string } }
// keep extra keys
rule().object({ name: rule().string() }, "passthrough");
// record of arbitrary keys, values are integers
rule().record(rule().integer()); // Record<string, number>
// record with constrained keys (valibot-style: key first, value second)
const Theme = { Light: "light", Dark: "dark" } as const;
rule().record(rule().enum(Theme), rule().string()); // Record<"light"|"dark", string>
// truly arbitrary JSON blob
rule().unknown(); // unknown
rule().record(); // Record<string, unknown>Errors compose naturally:
const v = Validator.make(
{ order: { id: "bad", customer: { name: "x", email: "nope" } } },
{
order: rule().object({
id: rule().integer(),
customer: rule().object({
name: rule().string(),
email: rule().string().email(),
}),
}),
},
);
await v.errors();
// {
// "order.id": ["The order.id field must be an integer."],
// "order.customer.email": ["The order.customer.email field must be a valid email address."],
// }When sub-errors are recorded, the parent rule's own "must be an object/record" message is suppressed — only the most specific paths surface. The top-level "must be an object/record" still fires when the value is the wrong shape (array, string, etc.).
null follows the framework convention: treated as missing unless .required(). Use rule().required().record() if null should be rejected.
Rule chains accumulate their output type:
const out = await Validator.make(input, {
age: rule().integer(),
role: rule().in(["admin", "member"] as const).default("member"),
nickname: rule().sometimes().string(),
deleted_at: rule().nullable().date(),
}).validate();
// out.age: number
// out.role: "admin" | "member"
// out.nickname?: string
// out.deleted_at: Date | null