Skip to content
Warlock.js v4

Reuse and compose schemas

You almost never have just one shape for an entity. There’s the create body, the update body, the public response, the login payload — all close cousins of one canonical shape. Copy-pasting them is how they drift. Seal gives you a small set of object methods that derive one schema from another, so a single base stays the source of truth.

Every method here returns a new ObjectValidator (schemas are immutable), and Infer<> follows the transformation — the derived type is always correct without a hand-written parallel.

import { v, type Infer } from "@warlock.js/seal";
const userSchema = v.object({
id: v.int(),
name: v.string().min(2),
email: v.string().email(),
password: v.string().min(8),
role: v.literal("admin", "member").default("member"),
});
// Create — the client doesn't send the server-assigned id.
const createUser = userSchema.without("id");
// { name; email; password; role? }
// Update — everything optional, but id stays required to know what to patch.
const updateUser = userSchema.partial().requiredFields("id");
// { id; name?; email?; password?; role? }
// Login — only the two fields the endpoint needs.
const loginUser = userSchema.pick("email", "password");
// { email; password }
// Public response — never leak the password.
const publicUser = userSchema.without("password");
// { id; name; email; role? }

Four endpoints, one base. Add a field to userSchema and it flows everywhere it should.

Keep only some fields, or drop some fields. Inverses of each other. Both preserve the original’s config (allowUnknown, etc.).

userSchema.pick("email", "password"); // ObjectValidator<{ email; password }>
userSchema.without("id", "password"); // ObjectValidator<{ name; email; role }>

.partial(...keys?) / .requiredFields(...keys?)

Section titled “.partial(...keys?) / .requiredFields(...keys?)”

Flip fields to optional, or back to required. Pass specific keys, or none to apply to every field.

userSchema.partial(); // every field optional
userSchema.partial("password"); // only password optional
userSchema.partial().requiredFields("id", "email"); // all optional except id + email

.extend(schemaOrValidator) — add fields, keep your config

Section titled “.extend(schemaOrValidator) — add fields, keep your config”

.extend bolts extra fields onto a clone and keeps the original’s configuration. Handy for reusable field bundles like timestamps. If you pass another ObjectValidator, only its schema is used — its config is ignored.

const timestamps = v.object({
createdAt: v.date(),
updatedAt: v.date(),
});
const userWithAudit = userSchema.extend(timestamps);
// { id; name; email; password; role?; createdAt; updatedAt }
// keeps userSchema's config

.merge(otherValidator) — combine fields and config

Section titled “.merge(otherValidator) — combine fields and config”

.merge is .extend’s heavier sibling: it merges both the schema and the configuration, with the other validator’s config winning. Rules, mutators, transformers, and attribute display names from both are appended.

const base = v.object({ name: v.string() }).allowUnknown();
const audit = v.object({ createdAt: v.date() }).stripUnknown();
const merged = base.merge(audit);
// fields: { name; createdAt }
// config: stripUnknown() from audit wins over base's allowUnknown()

.extend vs .merge in one line: reach for .extend when you only want the fields from the other shape; reach for .merge when you also want its behaviour.

Composition isn’t only at the object level — individual field validators are values you can .clone() and specialize. Because chains are immutable, deriving never mutates the source:

const baseString = v.string().min(3).max(50);
const username = baseString.clone().alphanumeric().lowercase();
const slug = baseString.clone().slug();
// baseString is untouched — still just min(3).max(50).

A schema you hand to a Cascade Model (or store in a module-level constant) is shared. If chaining mutated in place, deriving updateUser from userSchema would corrupt userSchema for every other consumer. Seal’s clone-on-write default prevents that — each derivation is a fresh snapshot. You’d only ever opt into the in-place .mutable getter when building a schema dynamically and you’re sure no one else holds a reference.