@lunora/values is the validator runtime behind Lunora: the v.* factories and
the TypeScript inference (Infer) that pairs with them. @lunora/server
re-exports the v namespace, so in application code you usually import it from
there. Import this package directly only when you write a shared validators
library, generate JSON Schema, or test validation in isolation.
import { type Infer, v, ValidationError } from "@lunora/values";
const message = v.object({
channelId: v.id("channels"),
text: v.string(),
pinned: v.optional(v.boolean()),
});
type Message = Infer<typeof message>;
// ^? { channelId: Id<"channels">; text: string; pinned?: boolean }
const parsed = message.parse(input); // throws ValidationError on mismatch
const result = message.safeParse(input);
if (!result.ok) {
console.error(result.error.path, result.error.expected);
}Each validator also implements Standard Schema
v1 (vendor: "lunora"), so tools that accept a Standard Schema can consume a
Lunora validator directly.
Validator factory (v.*)
| Factory | Accepts | Notes |
|---|---|---|
v.string() | string | UTF-8 text |
v.number() | number | Finite only; NaN/±Infinity throw |
v.boolean() | boolean | true / false |
v.null() | null | Only null |
v.bigint() | bigint | Stored as INTEGER (int64) in D1 |
v.bytes() | ArrayBuffer or any view | Binary; stored as BLOB. A view copies to its own bytes |
v.any() | anything | Escape hatch; no runtime check — see below |
v.id(table) | Id<table> | Branded id string for the given table |
v.literal(value) | exact value | string / number / boolean / bigint / null |
v.array(inner) | Array<Infer<inner>> | Homogeneous array |
v.object(shape) | { ...shape } | Plain object; declared keys parsed, extra keys ignored |
v.union(a, b, ...) | any member | Tries members in order; needs ≥ 1 member |
v.optional(inner) | Infer<inner> | undefined | Marks a field optional (missing/undefined) |
v.record(key, value) | Record<key, value> | Map-style; key validator must produce a string |
v.timestamp() | number | Epoch milliseconds; pair with .defaultNow() |
v.date() | number | Calendar date as epoch ms; pair with .defaultNow() |
v.storage(bucket?) | string | An R2 object key; ties the data model to a bucket |
v.from(schema) | inferred from schema | Wrap a Standard Schema validator (args or column) |
Every factory returns a Validator with parse(value) (throws
ValidationError) and safeParse(value) (returns
{ ok: true, value } | { ok: false, error }).
Which keys are optional
A key is optional when its validator accepts the field absent — which is
broader than v.optional(...):
v.optional(inner)— the explicit spelling.v.any()— it returns its input unchanged, soundefinedparses. A barev.any()field is thereforedata?: unknown, notdata: unknown.v.union(a, b, ...)with ananyorv.optional(...)member — the union tries its members, and one of them acceptsundefined.
One rule, applied everywhere: Infer/InferValidatorMap key off it, required
in the emitted JSON Schema is its complement, and @lunora/codegen renders
key?: T from it in _generated/api.ts. So a procedure's handler args and the
generated reference for a procedure declaring the same validator always agree.
v.partial(shape)
Wraps every member of a shape in v.optional(...) — the "any subset of these
fields" shape a patch-style procedure takes. It works on a shape record, so the
same call serves an args map and a nested object:
const editable = { body: v.string(), title: v.string() };
mutation.input({ id: v.id("threads"), ...v.partial(editable) }).mutation(…);
v.object(v.partial({ done: v.boolean(), title: v.string() }));
// ^ { done?: boolean; title?: string }A member that is already v.optional(...) is passed through, so it is
idempotent.
Pass the fields a caller may patch — not schema.tables.x.shape. Spreading a whole table's shape into a patch procedure makes every present and future
column client-writable: add ownerId, role, or isVerified to the table later and it silently joins the patch, with no diff on the procedure for a
reviewer to catch. Row-level policies don't close this — they decide which row you may write, not which fields — so the allow-list belongs here.
v.from(...) works both as query/mutation/action args and as a defineTable column; the Standard Schema runs on every write. A column is stored by
the value's runtime type — a scalar verbatim (typed plain text on a .global() table), an object or array JSON-encoded — so a stored string that itself
looks like JSON is ambiguous on read. @lunora/seed cannot invent a conforming value, so it refuses to seed such a column. The wrapped validate must be
synchronous; an async result throws.
Column modifiers
Inside defineTable, the same factories carry a chainable column API (these are
inert in args position):
import { defineTable, v } from "@lunora/server";
export const users = defineTable({
name: v.string(),
email: v.string().unique(),
role: v.optional(v.union(v.literal("admin"), v.literal("member"))),
bio: v.string().nullable(), // widens the read type to `string | null`
createdAt: v.timestamp().defaultNow(),
// SERVER-trusted: overwritten on every write from the request auth, so it is
// never client-controllable.
ownerId: v.string().serverDefault(({ auth }) => auth.userId),
});.default(value)/.$defaultFn(() => value): fill an absent field; the field becomes optional on insert..$onUpdateFn(() => value): recompute on every patch/replace..serverDefault(({ auth }) => value): stamp server-side on every write, overwriting any client value..nullable(): allow SQLNULL; widens select/insert to includenull..unique(): synthesize a UNIQUE index..$type<T>(): retype select/insert without changing runtime parsing..defaultNow():v.timestamp()/v.date()only; defaults toDate.now().
Refinements and metadata
.check() adds a predicate that runs after parsing; .meta() attaches JSON
Schema metadata with no parsing effect. Both chain.
const slug = v
.string()
.check((s) => s.length > 0, { message: "non-empty", schema: { minLength: 1 } })
.meta({ description: "URL slug" });The schema fragment and description flow into the JSON Schema emitted by
toJsonSchema.
Id<TableName>
A nominally-typed string brand. lunora/_generated/dataModel.ts re-exports Id
so you can type your own helpers:
import type { Id } from "@/lunora/_generated/dataModel";
const acl = (userId: Id<"users">) => {
/* ... */
};Id<T> carries the table name only at compile time. Use v.id("users") when
you also need the runtime check.
Infer<T>
Given a Validator, expand it to the inferred TypeScript type:
type T = Infer<typeof v.object({ foo: v.string() })>;
// ^? { foo: string }For columns, InferSelect<T> / InferInsert<T> give the read and write types
separately (insert types may be optional via .default()/.$defaultFn()/v.optional).
ValidationError
Thrown by parse(value) and surfaced by query / mutation / action when
the input shape doesn't match the declared args. It carries:
path: ValidationPath:(string | number)[]from the root to the offending field, e.g.["users", 0, "email"].expected: string/received: string: the expected shape and a short description of what arrived.
Helpers: formatPath(path) renders a path as users[0].email (<root> when
empty), and describeValue(value) produces the short summary used in messages.
ValidatorKind
String union of every supported kind ("string" | "number" | … | "from"),
useful for switch-based reflection. @lunora/codegen uses it to map validators
to SQL types.
JSON Schema
toJsonSchema(validator) converts one validator to a JSON Schema node (Draft
2020-12 / OpenAPI 3.1). argsToJsonSchema(argsMap) converts a function's arg map
to a single object schema; required lists every arg that does not accept an
absent field (see Which keys are optional).
acceptsAbsent(node, reader) is that predicate, exported so a build-time consumer
holding its own validator representation can answer the question the same way.
import { argsToJsonSchema, v } from "@lunora/values";
const schema = argsToJsonSchema({ id: v.id("users"), limit: v.optional(v.number()) });
// ^? { type: "object", properties: {...}, required: ["id"] }Validator maps
parseValidatorMap(validators, source, label) validates each declared field of
source through its validator, re-wrapping any ValidationError with a
<label>.<key>: prefix. It is the shared arg/field parser used by the procedure
builder, the HTTP route builder, and reusable workflow steps. InferValidatorMap<A>
gives the object type of such a map, with the optionality rule above applied to
each key.