Zod
Benni’s core already accepts any Standard Schema
validator via json(schema), but Standard
Schema only defines one direction, so that validates reads only, and
writes are a blind JSON.stringify. Zod codecs
(Zod 4.1+) define both directions, and benni/zod runs them both:
- Writes are validated. A bad value throws
ValidationErrorat theset, before anything is sent, not at some later read in another process. - Rich types round-trip.
Date,bigint,URL, custom classes, stored in their string / JSON-safe form, revived on read. With plainjson<T>(), aDatefield silently comes back as astring.
import * as z from "zod";import { kv } from "benni/schema";import { zodCodec, zodJson } from "benni/zod";
const isoDate = z.codec(z.iso.datetime(), z.date(), { decode: (iso) => new Date(iso), encode: (date) => date.toISOString()});
const user = z.object({ name: z.string(), created: isoDate });export const users = kv("user", zodJson(user));
await redis.kv(users).set("u1", { name: "ada", created: new Date() });const found = await redis.kv(users).get("u1");// ^? { name: string; created: Date } | null (created is a real Date)Zod is an optional peer dependency (zod@^4.1.0); only the
benni/zod subpath imports it. The adapter is built against zod/v4/core,
so schemas from both zod and zod/mini work.
zodCodec(schema): string-stored fields
Section titled “zodCodec(schema): string-stored fields”Takes any Zod schema or codec whose encoded (input) side is a string and
returns a Benni Codec. Use it anywhere a codec is accepted: kv values,
hash fields, list items, set and sorted-set members, stream fields, pub/sub
messages.
import { hash, string } from "benni/schema";import { zodCodec } from "benni/zod";
export const sessions = hash("session", { userId: string(), expiresAt: zodCodec(isoDate) // Date in your code, ISO string in Redis});A plain string schema works too: zodCodec(z.email()) stores the string
as-is and validates it in both directions. Passing a schema whose encoded
side isn’t a string (z.number(), z.date(), …) is a compile error.
zodJson(schema): JSON-stored values
Section titled “zodJson(schema): JSON-stored values”A stronger json(schema): writes run
z.encode (validated, codec fields converted to their JSON-safe form) then
JSON.stringify; reads run JSON.parse then z.decode (validated, codec
fields revived).
Fields that aren’t JSON-safe need a codec to a JSON-safe form, like isoDate
above. A bare z.date() field would stringify on write but fail loudly on
read, which is still better than json<T>()’s silent type lie, but a codec
is the actual fix.
Useful codecs
Section titled “Useful codecs”Zod doesn’t ship codec presets; its codecs page
maintains copy-paste implementations for the common ones: Date ↔ ISO
string (above), plus:
const bigintString = z.codec(z.string().regex(/^-?\d+$/), z.bigint(), { decode: (s) => BigInt(s), encode: (b) => b.toString()});
const urlString = z.codec(z.url(), z.instanceof(URL), { decode: (s) => new URL(s), encode: (url) => url.href});Errors
Section titled “Errors”The adapter maps into Benni’s unified error classes:
- Encode failures throw
ValidationError, a caller mistake; nothing is sent to Redis. - Decode failures throw
ReplyShapeErrorwith the stored string attached as.reply, and the message names the failing paths (created: Invalid ISO datetime). - Async schemas (
.refine(async …)) can’t run in a synchronous codec; both directions throwValidationErrortelling you so. If such a refinement rejects instead of just failing, zod discards that promise internally, so the rejection also surfaces as an unhandled rejection Benni cannot claim. Keep async work out of the schema. zodJsonrefuses values JSON cannot carry faithfully:NaN,Infinity,BigInt, and circular structures all throwValidationErrorbefore the write, exactly as the plainjson()codec does. A non-finite number would otherwise be stored asnulland read back as if the key were missing.zodCodecneeds a schema whose encoded side really is a string.z.any()satisfies the type constraint without checking anything, so a non-string encode result throwsValidationErrorrather than reaching Redis as[object Object].