Skip to content
DocsconceptsDocumentation

Validation

The v.* validators that type your columns and parse query / mutation / action inputs and outputs end to end.

Last updated:

A validator is a single runtime object that does two jobs at once: it parses a value at runtime (throwing ValidationError on a mismatch) and it carries the TypeScript type that value infers to. The same v.* validators describe your storage columns and guard the boundary of every function. One declaration drives both the database shape and the function's contract, and the inferred types flow all the way to the generated client.

// In schema.ts — table + column definitions:
import { defineSchema, defineTable, v } from "lunorash/server";

// In a query/mutation/action — the typed builders + v:
import { mutation, query, v } from "@/lunora/_generated/server";

Validators come from @lunora/values; @lunora/server re-exports the v namespace for schema files. Inside query / mutation / action files, import v (and the builders) from the generated @/lunora/_generated/server instead; its v.id(...) is branded to your tables.

Job 1: column types in defineTable

Each key of a defineTable({...}) shape is a column, and its value is the validator for that column. The validator decides the SQL column type, the read/write type, and whether null is accepted:

// lunora/schema.ts
import { defineSchema, defineTable, v } from "lunorash/server";

export default defineSchema({
    messages: defineTable({
        channelId: v.id("channels"), // branded FK
        text: v.string(),
        pinned: v.optional(v.boolean()),
        reactions: v.array(v.string()),
    }),
});

Column validators also carry the constraint modifiers (.unique(), .nullable(), .default(…)), and v.id("table") is the foreign-key type used by relations.

Job 2: argument and return validation

The query / mutation / action builders take their input contract from .input({...}), a map of field name to validator. The runtime parses the incoming args through it before your handler runs, so a malformed call fails with ValidationError and never reaches your code. Inside the handler, args is fully typed from those validators:

import { mutation, v } from "@/lunora/_generated/server";

export const send = mutation
    .input({
        channelId: v.id("channels"),
        text: v.string(),
        pinned: v.optional(v.boolean()),
    })
    .mutation(async ({ ctx, args }) => {
        // args: { channelId: Id<"channels">; text: string; pinned?: boolean }
        return ctx.db.insert("messages", { ...args, userId: ctx.auth.userId });
    });

.input(...) is chainable and additive: call it more than once and the maps merge. The terminal method (.query(), .mutation(), .action()) takes the handler and marks the function kind.

Validating the return value with .output(...)

.output(validator) parses the handler's result through a validator before it leaves the server, and narrows the function's return type to Infer<V>. Without it the return type is whatever your handler infers; with it the declared validator is the contract:

export const getProfile = query
    .input({ userId: v.id("users") })
    .output(v.object({ id: v.id("users"), name: v.string() }))
    .query(async ({ ctx, args }) => {
        const user = await ctx.db.get(args.userId);
        return { id: user._id, name: user.name };
    });

Returning more than you declared

On the way in, v.object drops keys it does not declare — that is what stops an over-posted field reaching your handler. On the way out, the same behaviour would delete a field the server meant to send, so .output() rejects undeclared keys instead:

.output(v.object({ id: v.id("users"), name: v.string() }))
.query(async ({ ctx, args }) => {
    const user = await ctx.db.get(args.userId);

    return user; // ← 500: "object has 3 undeclared key(s): email, createdAt, …"
});

The error names the keys, so the fix is either adding them to the validator or saying you meant to drop them.

Narrowing on purpose: .strip()

Trimming an internal field off a row before it reaches a client is a real use of .output(). Mark it and it works exactly as before:

.output(v.object({ id: v.id("users"), name: v.string() }).strip())
.query(async ({ ctx, args }) => ctx.db.get(args.userId)); // email, passwordHash dropped

.strip() returns a new validator, so calling it on a shared const does not change what other procedures using that const enforce.

This is the one asymmetry worth remembering: input strips, output rejects. Without it, a column present in your table and missing from a hand-written output validator vanished from every response that function served, with no error anywhere — a schema mistake turning into silent data loss. .strip() keeps the narrowing available and makes it visible in review.

.output() and .stream() cannot be combined — it is a type error. Stream chunks are yielded as-is with no per-chunk validation, so accepting an output validator there would promise something it never did. Validate inside the handler, or return the whole payload from .query().

Return-type inference to the client

Codegen emits a typed api object from your function exports. The client's useQuery / useMutation (and the bare client calls) read the inferred input and output types straight off that api, so the same validators that parse on the server type the client call site. Pass the wrong args shape, or use the result as the wrong type, and it's a compile error in the browser. There is no second schema to keep in sync: the validator is the contract on both ends.

Common validators

ValidatorAcceptsNotes
v.string()stringUTF-8 text
v.number()numberFinite JS number; NaN throws
v.boolean()booleantrue / false
v.null()nullOnly null
v.bigint()bigintStored lossless as INTEGER in D1
v.bytes()ArrayBuffer / Uint8ArrayBinary (BLOB in D1)
v.id(table)Id<table>Branded foreign-key id
v.literal(value)exact valueString / number / boolean / null literal
v.array(inner)Array<Infer<inner>>Homogeneous array
v.object(shape){ ...shape }Extra keys: stripped in, rejected out
v.union(a, b, ...)any memberDiscriminated by kind when present
v.record(key, value)Record<key, value>Map-style object
v.optional(inner)Infer<inner> | undefinedMakes a field optional
v.any()anythingEscape hatch; disables runtime checks

Compose them freely for both columns and function contracts: v.array(v.object({ ... })), v.union(v.literal("a"), v.literal("b")), v.optional(v.id("users")).

Coming from zod

v reads like zod and most of it behaves like zod, so the places it does not are worth knowing up front — three of the four below fail at runtime rather than at compile time.

  • v has refinements. .check(predicate, { message, schema }) is the general form, and three validators carry shortcuts over it. They are enforced at parse time and emitted into the JSON Schema. The shortcuts are per-type — v.number().length(3) and v.string().positive() do not exist:

    ValidatorShortcuts
    v.string().min() .max() .length() .email() .url() .pattern()
    v.number().min() .max() .int() .positive()
    v.array().min() .max() (item count)
  • .safeParse() returns a different shape. { ok: true, value } on success and { ok: false, error } on failure — not zod's { success, data }. .parse() matches zod. Reading .success off the result is a type error, so ported code fails to compile rather than silently taking the wrong branch — unless the value has been widened to any, where it reads as undefined and every check falls through to the failure path.

  • .default(x) is a column default, not an argument coercion. It declares what the database writes when a field is absent on insert; it does not fill a missing value in during parse. Reading a defaulted field out of args gives undefined.

  • v.object strips undeclared keys on input, and rejects them on output. There is no .strict(): input stripping is not configurable, and .output() is strict for you. A .check() cannot restore rejection on the input side, because the predicate receives the already-stripped value.

Genuinely absent: there is no v.enum (spell it as v.union(v.literal("a"), v.literal("b"))), no .extend(), and no loose-object mode.

ValidationError

When a value fails to parse, .parse(value) throws ValidationError (and .safeParse(value) returns it as { ok: false, error } instead). It carries the path to the offending field (rendered via formatPath as args.<field>.<sub>) plus a short describeValue(value) summary, so the boundary failure points at exactly which input was wrong. See @lunora/values.