| name | zod |
| description | Zod 4 schema authoring for snapmatch — every type comes from `z.infer<typeof Schema>`, every boundary input is parsed (never cast), and the dev-only `defineComponent(schema, fn)` wraps `schema.parse` so production tree-shakes the runtime check. Triggers on: parse, refine, safeParse, schema, transform, z.infer, z.object, zod, zod 4, ZodError. |
| license | MIT |
The producer for the schema universe. Owns "how to write a schema" and "how to read its parse failure"; every other Wave 2 skill (t3-env, idb, jotai) imports z from here and links back for authoring rules.
When to invoke
- Authoring a
z.object for component props, an atom value, an IDB/Firestore record, an env var, a
domain command/receipt, or a route param.
- Diagnosing a
ZodError surfaced in the browser console during dev.
- Choosing between
z.union, z.discriminatedUnion, .refine, .superRefine, .transform, or z.codec.
- Deriving a TypeScript type — must be
z.infer<typeof Schema>, never hand-written.
- Building or modifying the
defineComponent(schema, fn) helper.
Owns
Zod 4 schema authoring, z.infer, z.object, error formatting, codecs/transforms, JSON schema export, and the defineComponent(schema, fn) dev-only parse helper.
Defers to
ts — for tsgo --noEmit strictness and the import type rule under verbatimModuleSyntax. zod owns the "use z.infer" rule; ts owns the compiler config that makes it bite.
bun-test — for unit-testing schema edge cases (safeParse failure paths, refinement semantics).
Snapmatch stack rules
- Pillar 2 (Zod-first types) means: every props object, atom value, IDB record, env var, and route param starts as
z.object({...}); the TypeScript type is z.infer<typeof Schema>; module-boundary inputs are parsed (never as-cast). defineComponent(schema, fn) calls schema.parse(props) only when import.meta.env.DEV so production tree-shakes the call.
- Pillar 4 (CLI-gate-first) means: Zod runtime errors in the dev browser console are gate failures of the same severity as a TS error or a Biome warning — fix before continuing.
- Zod 4 only. v3 syntax (
z.string().email(), errorMap, err.format(), err.flatten(), .merge(), single-arg z.record(), z.nativeEnum(), .nonempty()) is wrong by definition — rewrite to the v4 API.
packages/schemas owns the SDK-free World/session/game/catalog, command/receipt, replica/outbox,
checkpoint, tombstone, quarantine, and recovery contracts. Firebase Timestamp instances are
decoded at app/state/remote/** into ResolvedTimestampSchema before IDB/Jotai. Pending
serverTimestamp() is explicit and never enters a committed replica.
- Every remotely durable union has a stable discriminator and strict object variants. Schema version,
nominal IDs, revisions, phase/deadline preconditions, and exact command/receipt identity are
validated at every Firestore and IDB ingress—not only when TypeScript compiles.
ReplicaNamespaceSchema recomputes the one namespace key from Auth UID, World, PlayerSession, and
Game-or-lobby identity. Every namespace-bearing wrapper reuses that cross-field refinement.
CommittedReplicaRecordSchema also binds entityId, parsed entity identity, namespace ownership,
and recordKey to the canonical Firestore document path; tombstones enforce the same identity.
Parsing each field independently is insufficient because it permits durable aliasing. The local
RetirementIntentSchema is anchored to that identity's canonical lobby namespace and binds the
tab-claim hash, reason, and staging time used by the IDB version-5 retry barrier.
Patterns
Schema → type → parse, the canonical loop
import * as z from "zod";
export const ScoreSchema = z.object({
value: z.int().min(0),
player: z.string().min(1),
});
export type Score = z.infer<typeof ScoreSchema>;
function loadScore(raw: unknown): Score {
return ScoreSchema.parse(raw);
}
z.int() is the safe-integer primitive (preferred over z.number().int()). The type comes from the schema — the rule is enforced cooperatively with ts.
defineComponent(schema, fn) — dev-only parse, prod tree-shake
import type { ZodType } from "zod";
export function defineComponent<S extends ZodType, R>(
schema: S,
fn: (props: z.infer<S>) => R,
): (props: z.infer<S>) => R {
if (import.meta.env.DEV) {
return (props) => fn(schema.parse(props));
}
return fn;
}
The if (import.meta.env.DEV) branch is statically replaced by Vite (true in dev, false in prod) so the production bundle drops the schema.parse call entirely. Component files use it like:
const PropsSchema = z.object({ label: z.string().min(1) });
export const Button = defineComponent(PropsSchema, ({ label }) => <button>{label}</button>);
safeParse for explicit control flow
const result = ScoreSchema.safeParse(raw);
if (!result.success) {
console.error(z.prettifyError(result.error));
for (const issue of result.error.issues) {
report({ path: issue.path, code: issue.code, message: issue.message });
}
return;
}
const score = result.data;
Use z.prettifyError, z.treeifyError, or z.flattenError — never the v3 .format() / .flatten() instance methods.
Discriminated union (preferred over z.union when a tag exists)
const EventSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("score"), value: z.int().min(0) }),
z.object({ kind: z.literal("levelUp"), to: z.int().min(1) }),
]);
type Event = z.infer<typeof EventSchema>;
O(1) routing on the discriminator. Reach for z.union([...]) only when no shared tag exists.
Refinement and cross-field check
const RangeSchema = z
.object({ min: z.int(), max: z.int() })
.refine((r) => r.min <= r.max, { error: "min must be ≤ max", path: ["max"] });
.refine for a single predicate; .superRefine((val, ctx) => ctx.addIssue({...})) for multi-issue checks. v3's .refinement(...) does not exist.
Codec for bidirectional boundary transforms
const dateCodec = z.codec(z.iso.datetime(), z.date(), {
decode: (s) => new Date(s),
encode: (d) => d.toISOString(),
});
const RecordSchema = z.object({ id: z.uuid(), at: dateCodec });
const value = z.decode(RecordSchema, raw);
const wire = z.encode(RecordSchema, value);
Plain .transform(...) is one-way and z.encode will throw on it at runtime. Use z.codec whenever the schema crosses a boundary in both directions (IDB write/read, network in/out).
For Firestore, keep the shared codec SDK-free. FirestoreTimestampCodec converts structural
{seconds, nanoseconds} parts to the IDB-safe resolved representation; the browser adapter alone
converts native Timestamp instances. Recursively decode native values before parsing a document,
and recursively encode resolved timestamps only after the domain schema succeeds.
Branded types for nominal IDs
const UserId = z.uuid().brand<"UserId">();
type UserId = z.infer<typeof UserId>;
const id: UserId = UserId.parse(raw);
Brands keep UserId and RoomId distinct at the type level even though both are strings at runtime.
Anti-patterns
- Don't hand-write a TS type that mirrors a schema — use
z.infer<typeof Schema> (ts enforces the same rule from the compiler side).
- Don't
as-cast at a module boundary — parse with safeParse / parse and let the failure carry information.
- Don't use v3 syntax —
z.string().email() → z.email(); errorMap / invalid_type_error / required_error / message → unified error param; .merge() → { ...A.shape, ...B.shape }; single-arg z.record(value) → z.record(key, value); z.nativeEnum(E) → z.enum(E); .nonempty() → .min(1); err.format()/err.flatten() → z.treeifyError/z.flattenError/z.prettifyError.
- Don't call
schema.parse(props) directly inside a component render — use defineComponent(schema, fn) so production tree-shakes the runtime call.
- Don't ignore a Zod console error in dev — Pillar 4 treats it as a gate failure. Fix it.
- Don't import Firebase into
packages/schemas — keep SDK instances and serverTimestamp() in
the remote adapter so schemas stay unit-testable and IDB-safe.
- Don't persist a pending server-timestamp sentinel as committed state — wait for a
server-confirmed document, decode it, then parse and commit it.
- Don't validate namespace, entity ID, and record path independently — use the shared
cross-field schemas so a well-formed but mismatched alias cannot enter IDB.
- Don't reach for
z.lazy(() => Self) for recursion — use the v4 getter form get children() { return z.array(Self); }.
Triggers on
parse, refine, safeParse, schema, transform, z.infer, z.object, zod, zod 4, ZodError