Typed Redis for TypeScript. All the way back.
Declare your data model once with typed codecs, bind a client, and call
methods named after the Redis commands they run. Your declared types
travel from write to read, so replies come back as your types,
not string | null.
import { hash, json, kv, number, string } from "benni/schema";
type UserProfile = {
name: string;
score: number;
};
export const users = hash("user", {
name: string(),
score: number()
});
export const profiles = kv("profile", json<UserProfile>()); Plain TypeScript values. They create no keys and run no migrations.
import { benni } from "benni";
import { node } from "benni/node";
import * as schema from "./schema";
const client = await node({
url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379"
});
export const redis = benni(client, { schema }); Bind once, export the handle. Swap this one import to change runtime.
import { redis } from "./redis";
await redis.query.users.hset("42", { name: "Ada", score: 10 });
const user = await redis.query.users.hget("42");
// ^? { name: string; score: number } | null
await redis.query.profiles.set("42", { name: "Ada", score: 10 }, {
ttlSeconds: 3600
});
const rawKey = redis.query.users.key("42"); // "user:42" Each store is reachable by its export name through redis.query.
Why
They type the commands. Benni types your data.
node-redis and ioredis are already typed, but
only at the command surface. A reply arrives as Redis's generic wire
shape, so you re-parse it and hand it a cast that the compiler agrees
to without checking. That cast is where the bugs live.
const raw = await client.get("profile:42");
// ^? string | null
const user = JSON.parse(raw!) as UserProfile;
// hand-cast. the compiler never checked it. const user = await redis.query.profiles.get("42");
// ^? UserProfile | null
// decoded by the declared codec. Design
A typed client, not an ORM.
It competes with raw client usage plus hand-rolled casts, not with an object mapper. The altitude is deliberate: command level, on purpose.
Command names stay
hgetall runs HGETALL. A type layer, not a query language, so everything you know about Redis still applies.
Schemas are values
No CLI, no codegen, no generated files. Declaring a schema creates no keys and runs nothing.
One round trip
hget is a single HMGET, not a pipeline of HGETs. On the edge, one call never quietly becomes four.
Nothing is hidden
No lazy loading, no identity map. The key is yours through .key(id), and redis.raw is always there.
Nothing is silent
An unexpected reply throws ReplyShapeError with the raw value attached. It never casts and moves on.
Works with your validator
Any Standard Schema validator, so Zod, Valibot, or ArkType, with zero added dependencies.
Runtimes
One core, thin adapters, the same typed API.
Redis in TypeScript is fragmented by runtime rather than by dialect. The adapter contract is four methods wide, which is what makes one portable API real. Already running ioredis? Hand Benni the instance you have. It shares that connection and closes only what it leased.
| Runtime | Adapter | Client |
|---|---|---|
| Node.js | benni/node | node-redis, an optional peer dependency |
| Node.js | benni/ioredis | Adopts an ioredis client you already have |
| Bun | benni/bun | Bun's built-in Redis client, no extra package |
| Deno | benni/node | npm:redis through Deno's npm compatibility |
| Edge | benni/upstash | Zero-dependency HTTP adapter, nothing but fetch |
Blocking commands, Pub/Sub subscribing, WATCH transactions,
and sessions need a persistent connection. The HTTP adapter declines
them rather than emulating them, and covers the whole typed surface
otherwise.
benni/primitives
Batteries only for what is easy to get wrong.
Built on any adapter, edge included. Not a search engine, not an index manager.
- queue()
- Built for model calls. Heartbeat leases so a ten-minute generation is ordinary, a resumable output stream per job, and cancellation that aborts the provider call instead of marking a row.
- cache()
- Read-through with stampede protection. Exactly one loader call per miss.
- ratelimit()
- An accurate sliding window, not a fixed bucket approximation. One atomic round trip per check.
- lock()
- A correct distributed lock. Never frees a lock that expired and was re-acquired by another holder.
Your types should survive the round trip.
紅 benni (n.) the deep crimson pigment pressed from safflower. Which makes this the red Redis client. We named it twice, in two languages, and noticed far too late.