Schema Builders
Schema builders are exported from benni/schema.
Codecs
Section titled “Codecs”string();number();boolean();json<T>();json(validator);bytes();enumOf(["pending", "active", "done"]);A codec controls how Benni writes values to Redis and decodes values returned by Redis. bytes() stores Uint8Array values as base64-encoded strings in Redis. enumOf([...]) constrains a field to a fixed set of string literals, stored as the plain string (no JSON overhead) and validated on decode, inferring the union of the values ("pending" | "active" | "done").
json has two forms, and the validating one is the default to reach for. json(validator) accepts any Standard Schema validator (Zod, Valibot, ArkType, …): every read is validated at runtime, and the value type is inferred from the validator, with no type parameter needed. Benni stays zero-dependency; the Standard Schema interface is inlined.
json<T>() is the escape hatch: a pure cast, with no runtime validation at all. JSON.parse runs and its result is asserted to be T. A stored value missing required fields, or carrying a field of the wrong type, is handed back typed as a complete T and nothing throws. Use it only where you own every writer of the key.
import { z } from "zod";
const users = kv("user", json(z.object({ name: z.string() }))); // validated// reads infer { name: string } | null from the Zod schemaconst profiles = kv("profile", json<Profile>()); // cast, uncheckedInvalid stored data throws a ReplyShapeError naming the validation issues. Async validators (schemas with async refinements) throw a clear error; json(validator) requires a synchronous validator.
Standard Schema defines only the read direction, so json(validator) cannot validate writes. The optional benni/zod subpath runs Zod codecs in both directions: zodCodec(schema) for string-stored fields (rich types like Date that round-trip) and zodJson(schema) as a write-validating json(validator).
number() rejects non-finite input (NaN/Infinity) at write time, so a bad value fails at the set rather than poisoning a later get. Decode failures (a malformed stored value, an out-of-set enum, a wrong reply shape) throw a ReplyShapeError (which carries the offending .reply); invalid caller input throws a ValidationError. Both extend TypeError, so existing catch blocks keep working while you can now discriminate the two.
When your app needs a codec Benni does not ship, pass a plain Codec object, anything with encode/decode:
import type { Codec } from "benni";
const uppercase: Codec<string, string> = { encode(value) { return value.toUpperCase(); }, decode(stored) { return stored; }};Key-Value
Section titled “Key-Value”const profiles = kv("profile", json<Profile>());Use with:
redis.kv(profiles);const users = hash("user", { name: string(), score: number()});Use with:
redis.hash(users);Collections
Section titled “Collections”const tags = set("tags", string());const events = list("events", json<Event>());const leaderboard = zset("leaderboard", string());const pageViews = hll("page-views", string());Use with:
redis.set(tags);redis.list(events);redis.zset(leaderboard);redis.hll(pageViews);Stream
Section titled “Stream”const activity = stream("activity", { action: string(), points: number()});Use with:
redis.stream(activity);Bitmap
Section titled “Bitmap”const dailyActive = bitmap("daily-active");Bitmaps take no codec; bits are addressed by offset and exposed as booleans. Use with:
redis.bitmap(dailyActive);const stores = geo("stores", string());Use with:
redis.geo(stores);Script
Section titled “Script”const rateLimit = script("rate-limit", { keys: ["counter"], args: { limit: number(), windowSeconds: number() }, returns: number(), lua: `return redis.call("INCR", KEYS[1])`});Use with:
redis.script(rateLimit);Pub/Sub
Section titled “Pub/Sub”const userEvents = channel("events:user", json<UserEvent>());const userEventPattern = pattern("events:user:*", json<UserEvent>());Use with:
redis.pubsub.channel(userEvents);redis.pubsub.pattern(userEventPattern);