- name
- implement-command
- description
- Add a new Redis command (or command variant) to node-redis end-to-end — the `<NAME>.ts` Command file, its registration with JSDoc in the package `commands/index.ts`, and a co-located `<NAME>.spec.ts` with arg + behavior tests. Use when asked to implement, add, or wire up a Redis command in the client or a module package (json/search/bloom/time-series).
# Implement a node-redis Command
## Overview
A command in node-redis is a single `Command` object exported from
`packages/<pkg>/lib/commands/<NAME>.ts`. It declares how to serialize arguments
onto the wire (`parseCommand`) and how to map the RESP reply to a JS value
(`transformReply`). It becomes callable on clients only after it is registered
in the package's `commands/index.ts`. Both the raw name and a camelCase alias
are exposed (`HSET` and `hSet`).
This skill is for `@redis/client` core commands and for module packages
(`@redis/json`, `@redis/search`, `@redis/bloom`, `@redis/time-series`). It does
not cover RESP codec changes or new client transports.
Before writing, **read 2-3 existing commands with a similar shape** (simple
key read, key + options, variadic, RESP2/3-divergent reply) and mirror them.
Consult [redis.io/commands](https://redis.io/commands) for argument order and
reply type, but treat existing `parseCommand`/`transformReply` files as the
source of truth for repo conventions.
## File layout
| Package | Command dir | Import paths | Wire name prefix |
| --- | --- | --- | --- |
| `client` | `packages/client/lib/commands/` | `../client/parser`, `../RESP/types`, `./generic-transformers` | none (`GET`) |
| `json` | `packages/json/lib/commands/` | `@redis/client/dist/lib/...` | `JSON.` (`JSON.ARRAPPEND`) |
| `search` | `packages/search/lib/commands/` | `@redis/client/dist/lib/...` | `FT.` |
| `bloom` | `packages/bloom/lib/commands/<family>/` | `@redis/client/dist/lib/...` | e.g. `BF.`, `CF.`, `TOPK.` |
| `time-series` | `packages/time-series/lib/commands/` | `@redis/client/dist/lib/...` | `TS.` |
**Naming**: file name = raw wire name with subcommand `_` separators
(`ACL_CAT`, `CONFIG_GET`, `CLUSTER_FORGET`). A distinct reply variant gets its
own file (`HRANDFIELD_COUNT_WITHVALUES`). Module commands drop the dotted
prefix from the file name (`ARRAPPEND.ts` → wire `JSON.ARRAPPEND`).
## Step 0 — Gather inputs (ask the user first)
New commands are often implemented **before they are publicly released**, so
redis.io may not document them yet and a default `redis:latest` may not have
them. Before writing any code, ask the user for three things:
1. **The command spec.** Ask for the redis/redis JSON spec file — one per
command under
[`src/commands/<name>.json`](https://github.com/redis/redis/tree/unstable/src/commands)
(subcommands use `-`, e.g. `client-info.json`, `acl-cat.json`). If the
command is unreleased, ask the user to paste the spec from their branch.
As a fallback on a live server: `redis-cli --json COMMAND DOCS <name>` and
`COMMAND INFO <name>`.
2. **A running Redis instance that has the command.** This is the single most
useful input — ask for it explicitly and up front. Ask for connection
details (host/port, TLS, auth, module loaded). Use it to explore real
behavior and confirm the implementation matches the spec — **do not rely on
the spec alone**. Probe every argument branch and diff the real reply against
your `transformReply`. A quick `redis-cli` session or a throwaway probe
script (`packages/client` is already wired for `tsx`) is enough; never commit
the probe.
3. **The first server version that ships the command.** Ask which Redis (or
module) version introduced it — the spec's `since` field is the answer when
present; otherwise ask the user directly. You need this to (a) write `@since`
in the JSDoc (Step 2) and (b) gate the behavior tests with
`minimumDockerVersion` (Step 3) so they don't run — and fail — on older
servers in CI.
If the user cannot provide a spec, derive arguments/reply from
[redis.io/commands](https://redis.io/commands) but flag that it is unverified.
If they cannot provide a live instance, implement from the spec but state that
runtime behavior was not confirmed. If the introducing version is unknown, say
so and leave `@since`/`minimumDockerVersion` out rather than guessing.
### Reading the spec JSON → mapping to a Command
The redis/redis spec drives every part of the `Command` object. Example
(`getex.json`, trimmed):
```json
{
"GETEX": {
"since": "6.2.0", "arity": -2,
"command_flags": ["WRITE", "FAST"],
"key_specs": [{ "begin_search": { "index": { "pos": 1 } }, "flags": ["RW", "UPDATE"] }],
"arguments": [
{ "name": "key", "type": "key", "key_spec_index": 0 },
{ "name": "expiration", "type": "oneof", "optional": true, "arguments": [
{ "name": "seconds", "type": "integer", "token": "EX" },
{ "name": "persist", "type": "pure-token", "token": "PERSIST" }
]}
],
"reply_schema": { "oneOf": [ { "type": "string" }, { "type": "null" } ] }
}
}
```
| Spec field | Drives |
| --- | --- |
| `command_flags` contains `READONLY` (and **not** `WRITE`) | `IS_READ_ONLY: true`. `WRITE` → omit it. Pure read with no side effects → also `CACHEABLE: true`. For keyed commands the generated metadata table already implies these defaults, so module packages omit the flags; keyless reads must set `IS_READ_ONLY: true` explicitly (Step 4). |
| `key_specs` empty / no `key`-type args | `NOT_KEYED_COMMAND: true`. |
| `arguments[].type: "key"` | `parser.pushKey(...)` (one per key, in spec order). |
| `type: "pure-token"` + `token` | a literal flag pushed only when its option is set (`parser.push('PERSIST')`). |
| `token` + value type (`integer`/`string`/...) | push the token then the stringified value (`parser.push('EX', seconds.toString())`). |
| `type: "oneof"` | mutually exclusive branch → `if/else if` in `parseCommand`; model as a union/`options` field. |
| `optional: true` | goes in the `options` object (exported `interface`); guard with `if (options?.x)`. |
| `multiple: true` | variadic → `parser.pushVariadic*`. |
| `arity` | sanity-check arg count in `parseArgs` tests. |
| `reply_schema` (JSON Schema) | the `transformReply` return type. `oneOf [string, null]` → `BlobStringReply \| NullReply`; `integer` → `NumberReply`; `array` → `ArrayReply<...>`; a map differing by RESP version → keyed `transformReply: { 2, 3 }`. |
| `since` | the introducing server version → `@since` in the registry JSDoc (Step 2) **and** `minimumDockerVersion: [major, minor]` on the behavior tests (Step 3). |
After implementing, run the command against the live instance and diff the real
reply against `reply_schema` and your `transformReply` output.
## Step 1 — Write `<NAME>.ts`
Minimal pass-through command (`packages/client/lib/commands/GET.ts`):
```typescript
import { CommandParser } from '../client/parser';
import { RedisArgument, BlobStringReply, NullReply, Command } from '../RESP/types';
export default {
CACHEABLE: true,
IS_READ_ONLY: true,
parseCommand(parser: CommandParser, key: RedisArgument) {
parser.push('GET');
parser.pushKey(key);
},
transformReply: undefined as unknown as () => BlobStringReply | NullReply
} as const satisfies Command;
```
`as const satisfies Command` is mandatory — it preserves the literal arg types
for the public API while type-checking the shape.
### Command flags (all optional)
- `IS_READ_ONLY: true` — read command; routable to replicas. Set for reads, omit/`false` for writes.
- `CACHEABLE: true` — eligible for client-side caching. Only for pure reads with no side effects.
- `NOT_KEYED_COMMAND: true` — command takes no key (server/connection level, e.g. `PING`, `CONFIG_GET`).
- `IS_FORWARD_COMMAND` — internal; do not set on new commands.
### `parseCommand` — serialize args via `CommandParser`
First arg is always `parser`. Push the wire name first, then args in order.
Use the parser helpers — **do not** hand-build arrays:
- `push(...args)` — raw args (the command token, flags, stringified numbers).
- `pushKey(key)` — a key. Registers it for cluster slot routing. Use for **every** key, never `push` a key.
- `pushKeys(keys)` / `pushKeysLength(keys)` — multiple keys; the `Length` variant prefixes the count.
- `pushVariadic(vals)` — a `RedisVariadicArgument` (one value or array) as flat args.
- `pushVariadicWithLength(vals)` — same, prefixed with the count (e.g. `FIELDS <n> ...`).
- `pushVariadicNumber(vals)` — number or array of numbers, stringified.
Numbers are not auto-stringified by `push` — call `.toString()`. Optional
trailing args go in an `options` object; export its `interface` (see
`SET.ts`'s `SetOptions`). Encode keyword flags conditionally:
```typescript
parseCommand(parser: CommandParser, key: RedisArgument, value: RedisArgument, options?: SetOptions) {
parser.push('SET');
parser.pushKey(key);
parser.push(value);
if (options?.condition) parser.push(options.condition); // 'NX' | 'XX'
}
```
### `transformReply` — map RESP reply to JS
- **Pass-through** (reply already the right shape): `transformReply: undefined as unknown as () => <ReplyType>`.
- **Function**: `(reply: <RawType>) => <JsType>`. Use `UnwrapReply<...>` to read the raw RESP container.
- **RESP-version keyed**: `{ 2: (reply) => ..., 3: (reply) => ... }` when RESP2 and RESP3 shapes differ (e.g. flat array vs map/tuple). See `HRANDFIELD_COUNT_WITHVALUES.ts`.
#### Unify RESP2 onto the RESP3 shape
When the server returns different shapes per protocol, the library exposes **one
return type to callers**: the **RESP3 shape is the source of truth**, and the
RESP2 reply is transformed to look like it. So the keyed form is almost always:
- `3:` — **pass-through** (`undefined as unknown as () => <ReplyType>`), because
RESP3 already has the target shape (map, tuple, big-number, double, ...).
- `2:` — a **function** that reshapes the flat/legacy RESP2 reply into that same
`<ReplyType>`. Type its input `UnwrapReply<Resp2Reply<ReplyType>>` so the raw
RESP2 container is visible while the output type still matches RESP3.
Canonical example — `HELLO.ts` turns the RESP2 flat array (`[k, v, k, v, ...]`)
into the RESP3 map, while RESP3 passes through:
```typescript
transformReply: {
2: (reply: UnwrapReply<Resp2Reply<HelloReply>>) => ({
server: reply[1], version: reply[3], proto: reply[5], /* ... */
}),
3: undefined as unknown as () => HelloReply
}
```
Reuse shared transformers where one exists (`HGETALL.ts` uses
`transformTuplesReply` for `2:`, map pass-through for `3:`). Only when RESP3
still needs reshaping does `3:` get its own function too. Verify the actual
per-protocol shapes against the live instance (Step 0) — connect once with
`RESP: 2` and once with `RESP: 3` and diff.
Reply types live in `RESP/types`: `BlobStringReply`, `SimpleStringReply<'OK'>`,
`NumberReply`, `DoubleReply`, `NullReply`, `BooleanReply`, `ArrayReply<T>`,
`TuplesReply<[...]>`, `MapReply`, `UnwrapReply`.
**RESP3 is the default.** No separate RESP3 test is needed for a new command;
the default test setup already exercises RESP3.
### Type-mapping precision caveats
- A `BLOB_STRING` reply cannot be remapped to `Number` via type mapping; only RESP3 `DOUBLE`/`BIG_NUMBER` are precision-risky.
- If a `NumberReply` can exceed `Number.MAX_SAFE_INTEGER` (2^53-1), add a `@remarks` line to the JSDoc (Step 2) telling users to do `client.withTypeMapping({ [RESP_TYPES.NUMBER]: String })`. See the `ARGREP` entries in the client index for the exact wording.
### Module package commands
Import from the published client subpath, prefix the wire name, and reuse
shared transformers (`packages/json/lib/commands/ARRAPPEND.ts`):
```typescript
import { CommandParser } from '@redis/client/dist/lib/client/parser';
import { RedisArgument, NumberReply, Command } from '@redis/client/dist/lib/RESP/types';
export default {
IS_READ_ONLY: false,
parseCommand(parser, key, path, value) {
parser.push('JSON.ARRAPPEND');
parser.pushKey(key);
parser.push(path, /* transform */ value);
},
transformReply: undefined as unknown as () => NumberReply
} as const satisfies Command;
```
## Step 2 — Register in `commands/index.ts`
`import` the command, then add it to the default-export map **twice**: the raw
name (shorthand) and a camelCase alias. **Every entry MUST have a JSDoc block
directly above it** — `npm run check:command-jsdoc` fails on any registry entry
without an attached JSDoc comment (no blank-line gap allowed).
```typescript
import GET from './GET';
// ...
export default {
View on GitHub