Node, Bun, Deno, and the edge

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.

Quick start Why Benni? $ npm install benni redis
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.

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.

Raw clientType lost at the edge
const raw = await client.get("profile:42");
//    ^? string | null

const user = JSON.parse(raw!) as UserProfile;
// hand-cast. the compiler never checked it.
BenniType carried through
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.

$ npm install benni redis Read the quick start

 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.