Schema Validation

Atoms and selectors accept an optional schema that validates values at runtime — and types your state without a generic. Validation is opt-in per store and off by default, so it adds zero cost unless you turn it on.

import { atom, store } from "valdres"
import { z } from "zod"

const userAtom = atom(
    { name: "Ada", age: 36 },
    {
        name: "userAtom",
        schema: z.object({ name: z.string(), age: z.number().min(0) }),
    },
)

const s = store({ schemaValidation: true })

s.set(userAtom, { name: "Bob", age: -1 })
// SchemaValidationError: Schema validation failed for 'userAtom': …

Two features in one

  1. Type inference. The schema is the single source of truth for the atom's type — atom(undefined, { schema: z.string() }) is Atom<string>, no generic needed. This works even when validation is off.
  2. Runtime validation. With validation enabled, every value entering the store is checked against the schema — catching the bugs TypeScript can't see (API responses with the wrong shape, form input, data crossing team boundaries).

Enabling validation

Validation is off by default. Enable it per store:

const devStore = store({ schemaValidation: true })

Scoped stores inherit the setting from their parent. A typical setup enables it everywhere except production:

const appStore = store({ schemaValidation: import.meta.env.DEV })

An individual atom or selector can override the store's setting:

// Always validated — even in a store with validation off.
// Useful for true boundary atoms (API responses, URL params).
const apiResponseAtom = atom(undefined, {
    schema: responseSchema,
    schemaValidation: true,
})

// Never validated — even in a store with validation on.
const hotPathAtom = atom(0, {
    schema: z.number(),
    schemaValidation: false,
})

Supported schema libraries

Any Standard Schema works — Zod 3.24+/4, Valibot, ArkType, and others — as well as any classic validator with a parse(value) method.

import * as v from "valibot"

const nameAtom = atom("Ada", { schema: v.string() })

When a schema offers both interfaces (like Zod), the parse path is used so the library's native error (e.g. a ZodError) is preserved on the thrown error's cause.

Validate-only semantics

The schema runs purely as a check — the original value is stored unchanged. A store with validation on holds exactly the same values as one with it off, so enabling validation in development can never make behavior diverge from production.

The flip side: one-way transforming or coercing schemas (z.coerce.number(), z.string().trim(), z.string().default(...)) validate but do not transform. Avoid them here — the inferred type follows the schema's output while the stored value stays the input. Do coercion at your data boundary (form handler, fetch layer) and give the atom a plain validator instead. Bidirectional codecs are the sanctioned exception — see below.

Wire codecs (zod ≥ 4.1)

A zod codec is a bidirectional schema: decode turns the wire representation into the runtime value, encode turns it back. Give an atom a codec and dehydrate/hydrate move JS-native values — BigInt, Date, Map, Set — over a plain JSON wire with no custom serializer:

import { atom, dehydrate, hydrate, store } from "valdres"
import { z } from "zod"

const bigintCodec = z.codec(z.string(), z.bigint(), {
    decode: s => BigInt(s),
    encode: b => b.toString(),
})
const mapCodec = z.codec(
    z.array(z.tuple([z.string(), z.number()])),
    z.map(z.string(), z.number()),
    { decode: entries => new Map(entries), encode: m => [...m.entries()] },
)
const setCodec = z.codec(z.array(z.string()), z.set(z.string()), {
    decode: values => new Set(values),
    encode: s => [...s],
})

const supplyAtom = atom(0n, { name: "app/supply", schema: bigintCodec }) // Atom<bigint>
const scoresAtom = atom(new Map(), { name: "app/scores", schema: mapCodec })
const tagsAtom = atom(new Set(), { name: "app/tags", schema: setCodec })

// server — the payload is plain JSON: BigInt is a string, Map/Set are arrays
const payload = dehydrate(serverStore)
const html = `<script>window.__STATE__ = ${JSON.stringify(payload)}</script>`

// client — decode restores the runtime values
hydrate(clientStore, window.__STATE__)
clientStore.get(supplyAtom)     // 900719925474099100n  (a real BigInt)
clientStore.get(scoresAtom)     // Map { "a" → 1 }

The atom stores the codec's output type (Atom<bigint>), and setting runtime values passes validation — the encode direction is what's checked. Decoding at hydrate inherently validates the wire value, so a tampered or stale payload entry fails loudly instead of landing silently.

The codec must sit at the non-JSON position. A plain z.bigint() or z.map(...) inside an object validates the runtime value but encodes as an identity — JSON.stringify will still throw on the BigInt and silently turn the Map into {}:

// ✗ validates, but cannot cross a JSON wire
z.object({ supply: z.bigint(), scores: z.map(z.string(), z.number()) })

// ✓ codecs at the non-JSON positions — nested codecs encode through
z.object({ supply: bigintCodec, scores: mapCodec })

Wire codecs are a zod ≥ 4.1 feature (any schema exposing safeEncode/safeDecode). Validation itself works with any supported schema library; atoms whose schema can't encode — including one-way transform schemas — transfer their raw value exactly as without a schema.

What gets validated

With validation enabled, values are checked at every write boundary:

BoundaryOn failure
store.set(atom, value)throws SchemaValidationError
Atom defaults (static, function, selector)throws on first read
Selector resultsthrows on evaluation
store.txn(...) writesthrows inside the txn body — the whole transaction aborts, nothing is committed
Async values (a promise resolving to an invalid value)reported via console.error; the invalid value is never committed

Async failures can't throw to the original caller (the promise was already returned), which is why they're reported instead. For an async set, the atom reverts to its previous value; for an async default, the value is dropped so a later read retries.

Error handling

Sync failures throw a SchemaValidationError (exported from valdres) that names the offending atom or selector and keeps the underlying error on cause:

import { SchemaValidationError } from "valdres"

try {
    s.set(userAtom, badValue)
} catch (err) {
    if (err instanceof SchemaValidationError) {
        console.log(err.message) // Schema validation failed for 'userAtom': …
        console.log(err.cause)   // the ZodError (or library-native error)
    }
}

Give your atoms a name — it's what makes these errors debuggable in an app with hundreds of atoms.

Limitations

  • Promises set inside a transaction are stored as-is and not auto-resolved by the transaction, so they aren't validated on resolve. Validate before setting, or set async values outside transactions.
  • Async defaults under React Suspense: an invalid async default drops its value (like a rejected one), so a component that keeps re-reading will re-initialize and re-fetch. Validate at the fetch boundary rather than relying on async-default validation under Suspense.
  • Asynchronous schemas (an async Standard Schema, or a Zod schema with an async refine) are not supported — validation runs synchronously and surfaces a clear error. Use synchronous schemas.

When to use it

Most internal state doesn't need runtime validation — TypeScript already covers a countAtom. Schemas earn their keep at system boundaries: API responses, form input, URL/storage-hydrated state, and atoms one team owns while another writes. A good default: type inference everywhere you like, runtime validation on boundary atoms via the per-atom override, and the store-wide flag on in development as a safety net.

See also

  • Core Concepts — atoms, selectors, and stores
  • TypeScript — more on type inference
  • The atom, selector, and store API pages document the schema and schemaValidation options in detail