Schemas

Schemas use Zod to define each record's fields. They validate data when it is:

  • Loaded from the local cache.
  • Changed by application code.
  • Received from the server.
  • Returned in a server response.

Define accounts and collections

import { defineAccount, defineCollection, type infer } from "valtio-sync/schema";
import { z } from "zod";

const account = defineAccount({
  fields: {
    theme: z.enum(["light", "dark"]).default("light"),
  },
});

const todos = defineCollection({
  fields: {
    id: z.string(),
    title: z.string().default(""),
    completed: z.boolean().default(false),
  },
});

type Todo = infer<typeof todos>;
type TodoFromZod = z.infer<typeof todos.recordSchema>; // Same as Todo

Reuse the record schema

Each definition exposes its Zod schema as recordSchema. It includes field defaults and transforms, and rejects undeclared fields.

Use it when you need to validate the same record elsewhere. You do not need a separate Zod object:

export const TodoRecord = todos.recordSchema;
export type TodoRecord = infer<typeof todos>;

Validate rules across fields

Use the definition-level refine callback for rules involving multiple fields. It has the same record and issue context as Zod's superRefine:

const account = defineAccount({
  fields: {
    maxPinnedTodos: z.number().int().nonnegative(),
    pinnedTodoIds: z.array(z.string()),
  },
  refine: (record, ctx) => {
    if (record.pinnedTodoIds.length > record.maxPinnedTodos) {
      ctx.addIssue({
        code: 'custom',
        path: ['pinnedTodoIds'],
        message: 'Pinned todos exceed the configured limit',
      })
    }
  },
})

Assemble the sync schema

Use exactly one account definition in each sync schema:

const schema = { account, todos }

Collections should include an id: z.string() field. The collection API creates records by id, and strict schema validation rejects values with fields that are not declared.

Set defaults

Defaults are applied when records are created and when saved local data is loaded:

const todo = sync.todos.create({ id: 'todo_1' })
todo.title // ""
todo.completed // false

Use JSON-compatible values

Synced records and patches must be plain objects that can be serialized as JSON. Avoid:

  • Date, Map, and Set.
  • Class instances and functions.
  • undefined, NaN, and infinite numbers.

Store encoded strings or plain objects instead.

Define local-only state

Local-only state uses the same field-map shape, but it is passed directly to the client as device or session fields rather than through defineAccount or defineCollection.