Skip to content
Warlock.js v4

Your first schema

This page walks through the full seal loop in one example. By the end you’ll have defined a schema, run validation, derived a TypeScript type from it, and emitted JSON Schema for an external consumer — all from a single declaration.

A user signup payload — email, optional age, role.

import { v, validate, type Infer } from "@warlock.js/seal";
const signupSchema = v.object({
email: v.string().email(),
age: v.int().min(13).optional(),
role: v.literal("admin", "user", "guest"),
});

Three field declarations:

  • email — must be a string, must match the email format. Required (the default inside v.object).
  • age — must be an integer, must be at least 13. Optional — the caller may omit it.
  • role — must be one of three literal strings. The literal narrows the type at compile time too.

v.object({...}) is the structural wrapper. Without it, fields have no parent to resolve cross-field rules against.

Call validate(schema, data) and branch on result.isValid:

const result = await validate(signupSchema, {
email: "ada@example.com",
role: "user",
});
if (result.isValid) {
console.log(result.data);
// → { email: "ada@example.com", role: "user" }
// Note: `age` is omitted, not undefined — see the optional-fields recipe.
} else {
console.error(result.errors);
}

validate() never throws on bad input. Bad input lands in result.errors as an array of { type, error, input } objects:

const bad = await validate(signupSchema, {
email: "not-an-email",
role: "stranger",
});
bad.isValid; // false
bad.errors;
// [
// { type: "email", error: "The email must be a valid email", input: "email" },
// ]
// (firstErrorOnly defaults to true — only one error surfaces per call)

Each error carries:

  • type — the rule name that failed ("email", "min", "required", "literal", …). Branch on this; it’s the stable identifier.
  • error — the human-facing message (translated if you configured a translateRule hook).
  • input — the dot-notation field path ("email", "address.city").

Set firstErrorOnly: false via configureSeal() to collect every error per field.

Infer<typeof schema> turns the schema into a TypeScript type:

type Signup = Infer<typeof signupSchema>;
// {
// email: string;
// age?: number;
// role: "admin" | "user" | "guest";
// }

Three things to notice:

  • age is optional because the schema marked it so — the type has age?: number.
  • role is the literal union "admin" | "user" | "guest", not the wider string. That’s the payoff for using v.literal(...) instead of v.string().oneOf([...]).
  • The schema is the single source of truth. Adding a field to the schema adds it to the type — no parallel type Signup = { ... } to keep in sync.

If you want the post-validation shape (after defaults fire, after .catch() rescues), use Infer.Output<typeof schema>. The inferring types essential page covers the input/output split in detail.

The same schema generates JSON Schema for downstream consumers:

signupSchema.toJsonSchema("draft-2020-12");
// {
// type: "object",
// properties: {
// email: { type: "string", format: "email" },
// age: { type: "integer", minimum: 13 },
// role: { enum: ["admin", "user", "guest"] },
// },
// required: ["email", "role"],
// additionalProperties: false,
// }

Four targets are built in:

  • "draft-2020-12" — modern JSON Schema (default).
  • "draft-07" — older tooling, Swagger 2.0.
  • "openapi-3.0" — OpenAPI 3.0 (uses nullable: true instead of type unions).
  • "openai-strict" — OpenAI’s structured-outputs strict mode.

Hand the result straight to OpenAI for typed model output:

import OpenAI from "openai";
const openai = new OpenAI();
const completion = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "Make me a sample signup payload." }],
response_format: {
type: "json_schema",
json_schema: {
name: "signup",
strict: true,
schema: signupSchema.toJsonSchema("openai-strict"),
},
},
});

The JSON Schema guide covers the differences between targets and the quirks of OpenAI strict mode.

One signupSchema constant gave you:

  1. Runtime validation that never throws.
  2. A typed Signup interface synced to the schema.
  3. JSON Schema for OpenAI / OpenAPI / AJV consumers.

No casts, no parallel types, no separate JSON Schema file.