Skip to content
DocspackagesDocumentation

@lunora/values

The validator runtime — v.*, Validator, Infer, Id, ValidationError, and JSON Schema export.

PackagesValues

@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.*)

FactoryAcceptsNotes
v.string()stringUTF-8 text
v.number()numberFinite only; NaN/±Infinity throw
v.boolean()booleantrue / false
v.null()nullOnly null
v.bigint()bigintStored as INTEGER (int64) in D1
v.bytes()ArrayBuffer or any viewBinary; stored as BLOB. A view copies to its own bytes
v.any()anythingEscape hatch; no runtime check — see below
v.id(table)Id<table>Branded id string for the given table
v.literal(value)exact valuestring / 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 memberTries members in order; needs ≥ 1 member
v.optional(inner)Infer<inner> | undefinedMarks a field optional (missing/undefined)
v.record(key, value)Record<key, value>Map-style; key validator must produce a string
v.timestamp()numberEpoch milliseconds; pair with .defaultNow()
v.date()numberCalendar date as epoch ms; pair with .defaultNow()
v.storage(bucket?)stringAn R2 object key; ties the data model to a bucket
v.from(schema)inferred from schemaWrap 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, so undefined parses. A bare v.any() field is therefore data?: unknown, not data: unknown.
  • v.union(a, b, ...) with an any or v.optional(...) member — the union tries its members, and one of them accepts undefined.

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 SQL NULL; widens select/insert to include null.
  • .unique(): synthesize a UNIQUE index.
  • .$type<T>(): retype select/insert without changing runtime parsing.
  • .defaultNow(): v.timestamp() / v.date() only; defaults to Date.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.