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 };
    });

.output() does not apply to streaming/subscription chunks — each chunk is yielded as-is. Use it for the single resolved value of a query / mutation / action.

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 throw
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 — v.array(v.object({ ... })), v.union(v.literal("a"), v.literal("b")), v.optional(v.id("users")) — for both columns and function contracts.

ValidationError

When a value fails to parse, .validate(value) throws ValidationError. 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.

See also