From 6c1c610c6ad86c80b3934977ea558ceebf0a1082 Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Tue, 6 Jan 2026 17:45:57 +0300 Subject: [PATCH 1/4] fix(client): unwrap constructors to primitives in type mapping Resolves: #2987 --- packages/client/lib/RESP/types.spec.ts | 133 +++++++++++++++++++++++++ packages/client/lib/RESP/types.ts | 116 +++++++++++---------- 2 files changed, 194 insertions(+), 55 deletions(-) create mode 100644 packages/client/lib/RESP/types.spec.ts diff --git a/packages/client/lib/RESP/types.spec.ts b/packages/client/lib/RESP/types.spec.ts new file mode 100644 index 00000000000..eebb2abb346 --- /dev/null +++ b/packages/client/lib/RESP/types.spec.ts @@ -0,0 +1,133 @@ +import { strict as assert } from 'node:assert'; +import { describe, it, before, after } from 'mocha'; +import { createClient } from '../../index'; +import { RESP_TYPES } from './decoder'; +import { VerbatimString } from './verbatim-string'; + +describe('Comprehensive RESP Type Mapping', () => { + let client: any; + + before(async () => { + client = createClient(); + await client.connect(); + }); + + + after(async () => { + if (client) { + await client.destroy(); + } + }); + + describe('Scalar Primitives', () => { + it('INTEGER: EXISTS returns number (0|1)', async () => { + const res: number = await client + .withTypeMapping({}) + .exists('some_key'); + + assert.strictEqual(typeof res, 'number'); + }); + + it('BIG_NUMBER: should infer as primitive bigint', async () => { + const res: bigint | string | number = await client + .withTypeMapping({ + [RESP_TYPES.BIG_NUMBER]: BigInt + }) + .hello(); + + assert.ok(typeof res === 'bigint' || typeof res === 'object'); + }); + + it('DOUBLE: should infer as primitive number', async () => { + const res: number | null = await client + .withTypeMapping({ + [RESP_TYPES.DOUBLE]: Number + }) + .hello(); + + assert.ok(res === null || typeof res === 'number' || typeof res === 'object'); + }); + }); + + describe('Complex Strings', () => { + it('VERBATIM_STRING: should map to string, not object', async () => { + const res: string | Buffer | VerbatimString = await client + .withTypeMapping({ + [RESP_TYPES.VERBATIM_STRING]: String + }) + .get('key'); + + assert.ok( + res === null || + typeof res === 'string' || + Buffer.isBuffer(res) + ); + }); + }); + + describe('Recursive Collections', () => { + it('ARRAY: should correctly infer nested mapped types', async () => { + const res: string[] = await client + .withTypeMapping({ + [RESP_TYPES.BLOB_STRING]: String + }) + .lRange('key', 0, -1); + + assert.ok(Array.isArray(res)); + }); + + it('SET: should correctly infer Set of primitives', async () => { + const res: Set | string[] = await client + .withTypeMapping({ + [RESP_TYPES.BLOB_STRING]: String + }) + .sMembers('key'); + + assert.ok(res instanceof Set || Array.isArray(res)); + }); + + it('MAP: should correctly infer Map with mapped keys and values', async () => { + const res: Map | Record = await client + .withTypeMapping({ + [RESP_TYPES.BLOB_STRING]: String + }) + .hGetAll('key'); + + assert.ok(res instanceof Map || typeof res === 'object'); + }); + }); + + describe('Edge Cases', () => { + it('SIMPLE_ERROR: should still return Error objects', async () => { + + try { + const res: Error = await client + .withTypeMapping({ + [RESP_TYPES.SIMPLE_ERROR]: Error + }) + .hello(); + assert.ok(typeof res === 'object'); + } catch (e) { + assert.ok(e instanceof Error); + } + }); + + it('NULL: should always remain null regardless of mapping', async () => { + const res: string | null = await client + .withTypeMapping({}) + .get('missing-key-random-12345'); + + assert.strictEqual(res, null); + }); + + it('hGet: should infer string | null (fixing string | {})', async () => { + const res: string | null = await client + .withTypeMapping({ + [RESP_TYPES.BLOB_STRING]: String + }) + .hGet('foo', 'bar'); + + assert.ok(res === null || typeof res === 'string'); + }); + }); +}); \ No newline at end of file diff --git a/packages/client/lib/RESP/types.ts b/packages/client/lib/RESP/types.ts index 8749bbdc7b0..d1d26e6ccbf 100644 --- a/packages/client/lib/RESP/types.ts +++ b/packages/client/lib/RESP/types.ts @@ -27,14 +27,14 @@ export interface RespType< export interface NullReply extends RespType< RESP_TYPES['NULL'], null -> {} +> { } export interface BooleanReply< T extends boolean = boolean > extends RespType< RESP_TYPES['BOOLEAN'], T -> {} +> { } export interface NumberReply< T extends number = number @@ -43,7 +43,7 @@ export interface NumberReply< T, `${T}`, number | string -> {} +> { } export interface BigNumberReply< T extends bigint = bigint @@ -52,7 +52,7 @@ export interface BigNumberReply< T, number | `${T}`, bigint | number | string -> {} +> { } export interface DoubleReply< T extends number = number @@ -61,7 +61,7 @@ export interface DoubleReply< T, `${T}`, number | string -> {} +> { } export interface SimpleStringReply< T extends string = string @@ -70,7 +70,7 @@ export interface SimpleStringReply< T, Buffer, string | Buffer -> {} +> { } export interface BlobStringReply< T extends string = string @@ -90,65 +90,65 @@ export interface VerbatimStringReply< T, Buffer | VerbatimString, string | Buffer | VerbatimString -> {} +> { } export interface SimpleErrorReply extends RespType< RESP_TYPES['SIMPLE_ERROR'], SimpleError, Buffer -> {} +> { } export interface BlobErrorReply extends RespType< RESP_TYPES['BLOB_ERROR'], BlobError, Buffer -> {} +> { } export interface ArrayReply extends RespType< RESP_TYPES['ARRAY'], Array, never, Array -> {} +> { } export interface TuplesReply]> extends RespType< RESP_TYPES['ARRAY'], T, never, Array -> {} +> { } export interface SetReply extends RespType< RESP_TYPES['SET'], Array, Set, Array | Set -> {} +> { } export interface MapReply extends RespType< RESP_TYPES['MAP'], { [key: string]: V }, Map | Array, Map | Array -> {} +> { } type MapKeyValue = [key: BlobStringReply | SimpleStringReply, value: unknown]; type MapTuples = Array; type ExtractMapKey = ( - T extends BlobStringReply ? S : - T extends SimpleStringReply ? S : - never + T extends BlobStringReply ? S : + T extends SimpleStringReply ? S : + never ); export interface TuplesToMapReply extends RespType< RESP_TYPES['MAP'], { - [P in T[number] as ExtractMapKey]: P[1]; + [P in T[number]as ExtractMapKey]: P[1]; }, Map, T[number][1]> | FlattenTuples -> {} +> { } type FlattenTuples = ( T extends [] ? [] : @@ -193,32 +193,38 @@ type MapKey< [RESP_TYPES.BLOB_STRING]: StringConstructor; }>; +type UnwrapConstructor = + T extends StringConstructor ? string : + T extends NumberConstructor ? number : + T extends BooleanConstructor ? boolean : + T extends BigIntConstructor ? bigint : + T; export type UnwrapReply> = REPLY['DEFAULT' | 'TYPES']; export type ReplyWithTypeMapping< REPLY, TYPE_MAPPING extends TypeMapping > = ( - // if REPLY is a type, extract the coresponding type from TYPE_MAPPING or use the default type - REPLY extends RespType ? + // if REPLY is a type, extract the coresponding type from TYPE_MAPPING or use the default type + REPLY extends RespType ? TYPE_MAPPING[RESP_TYPE] extends MappedType ? - ReplyWithTypeMapping, TYPE_MAPPING> : - ReplyWithTypeMapping - : ( - // if REPLY is a known generic type, convert its generic arguments - // TODO: tuples? - REPLY extends Array ? Array> : - REPLY extends Set ? Set> : - REPLY extends Map ? Map, ReplyWithTypeMapping> : - // `Date | Buffer | Error` are supersets of `Record`, so they need to be checked first - REPLY extends Date | Buffer | Error ? REPLY : - REPLY extends Record ? { - [P in keyof REPLY]: ReplyWithTypeMapping; - } : - // otherwise, just return the REPLY as is - REPLY - ) -); + ReplyWithTypeMapping>, TYPE_MAPPING> : + ReplyWithTypeMapping + : ( + // if REPLY is a known generic type, convert its generic arguments + // TODO: tuples? + REPLY extends Array ? Array> : + REPLY extends Set ? Set> : + REPLY extends Map ? Map, ReplyWithTypeMapping> : + // `Date | Buffer | Error` are supersets of `Record`, so they need to be checked first + REPLY extends Date | Buffer | Error ? REPLY : + REPLY extends Record ? { + [P in keyof REPLY]: ReplyWithTypeMapping; + } : + // otherwise, just return the REPLY as is + REPLY + ) + ); export type TransformReply = (this: void, reply: any, preserve?: any, typeMapping?: TypeMapping) => any; // TODO; @@ -342,17 +348,17 @@ type Resp2Array = ( export type Resp2Reply = ( RESP3REPLY extends RespType ? - // TODO: RESP3 only scalar types - RESP_TYPE extends RESP_TYPES['DOUBLE'] ? BlobStringReply : - RESP_TYPE extends RESP_TYPES['ARRAY'] | RESP_TYPES['SET'] ? RespType< - RESP_TYPE, - Resp2Array - > : - RESP_TYPE extends RESP_TYPES['MAP'] ? RespType< - RESP_TYPES['ARRAY'], - Resp2Array>> - > : - RESP3REPLY : + // TODO: RESP3 only scalar types + RESP_TYPE extends RESP_TYPES['DOUBLE'] ? BlobStringReply : + RESP_TYPE extends RESP_TYPES['ARRAY'] | RESP_TYPES['SET'] ? RespType< + RESP_TYPE, + Resp2Array + > : + RESP_TYPE extends RESP_TYPES['MAP'] ? RespType< + RESP_TYPES['ARRAY'], + Resp2Array>> + > : + RESP3REPLY : RESP3REPLY ); @@ -362,13 +368,13 @@ export type CommandReply< COMMAND extends Command, RESP extends RespVersions > = ( - // if transformReply is a function, use its return type - COMMAND['transformReply'] extends (...args: any) => infer T ? T : - // if transformReply[RESP] is a function, use its return type - COMMAND['transformReply'] extends Record infer T> ? T : - // otherwise use the generic reply type - ReplyUnion -); + // if transformReply is a function, use its return type + COMMAND['transformReply'] extends (...args: any) => infer T ? T : + // if transformReply[RESP] is a function, use its return type + COMMAND['transformReply'] extends Record infer T> ? T : + // otherwise use the generic reply type + ReplyUnion + ); export type CommandSignature< COMMAND extends Command, From 8b43ac57707b6bba2d2dd8a08148008a4b32a3f9 Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Fri, 30 Jan 2026 14:55:11 +0300 Subject: [PATCH 2/4] Addressed Timeout issue in asynchorous tests --- packages/client/lib/RESP/types.spec.ts | 67 ++++++++++++-------------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/packages/client/lib/RESP/types.spec.ts b/packages/client/lib/RESP/types.spec.ts index eebb2abb346..a7f3dd7ffa6 100644 --- a/packages/client/lib/RESP/types.spec.ts +++ b/packages/client/lib/RESP/types.spec.ts @@ -1,57 +1,54 @@ import { strict as assert } from 'node:assert'; -import { describe, it, before, after } from 'mocha'; +import { describe, it, beforeEach, afterEach } from 'mocha'; import { createClient } from '../../index'; import { RESP_TYPES } from './decoder'; -import { VerbatimString } from './verbatim-string'; describe('Comprehensive RESP Type Mapping', () => { let client: any; - before(async () => { + beforeEach(async () => { client = createClient(); await client.connect(); }); - - after(async () => { - if (client) { - await client.destroy(); - } + afterEach(async () => { + await client.destroy(); }); describe('Scalar Primitives', () => { it('INTEGER: EXISTS returns number (0|1)', async () => { - const res: number = await client - .withTypeMapping({}) - .exists('some_key'); - + const res = await client.withTypeMapping({}).exists('some_key'); assert.strictEqual(typeof res, 'number'); }); - it('BIG_NUMBER: should infer as primitive bigint', async () => { - const res: bigint | string | number = await client + it('BIG_NUMBER: maps to bigint when configured', async () => { + const res = await client .withTypeMapping({ [RESP_TYPES.BIG_NUMBER]: BigInt }) .hello(); - assert.ok(typeof res === 'bigint' || typeof res === 'object'); + assert.ok( + typeof res === 'bigint' || + typeof res === 'number' || + typeof res === 'object' + ); }); - it('DOUBLE: should infer as primitive number', async () => { - const res: number | null = await client + it('DOUBLE: maps to number', async () => { + const res = await client .withTypeMapping({ [RESP_TYPES.DOUBLE]: Number }) .hello(); - assert.ok(res === null || typeof res === 'number' || typeof res === 'object'); + assert.ok(res === null || typeof res === 'number'); }); }); describe('Complex Strings', () => { - it('VERBATIM_STRING: should map to string, not object', async () => { - const res: string | Buffer | VerbatimString = await client + it('VERBATIM_STRING maps to string', async () => { + const res = await client .withTypeMapping({ [RESP_TYPES.VERBATIM_STRING]: String }) @@ -66,8 +63,8 @@ describe('Comprehensive RESP Type Mapping', () => { }); describe('Recursive Collections', () => { - it('ARRAY: should correctly infer nested mapped types', async () => { - const res: string[] = await client + it('ARRAY infers nested mapped types', async () => { + const res = await client .withTypeMapping({ [RESP_TYPES.BLOB_STRING]: String }) @@ -76,8 +73,8 @@ describe('Comprehensive RESP Type Mapping', () => { assert.ok(Array.isArray(res)); }); - it('SET: should correctly infer Set of primitives', async () => { - const res: Set | string[] = await client + it('SET infers Set or array', async () => { + const res = await client .withTypeMapping({ [RESP_TYPES.BLOB_STRING]: String }) @@ -86,8 +83,8 @@ describe('Comprehensive RESP Type Mapping', () => { assert.ok(res instanceof Set || Array.isArray(res)); }); - it('MAP: should correctly infer Map with mapped keys and values', async () => { - const res: Map | Record = await client + it('MAP infers Map or object', async () => { + const res = await client .withTypeMapping({ [RESP_TYPES.BLOB_STRING]: String }) @@ -98,30 +95,30 @@ describe('Comprehensive RESP Type Mapping', () => { }); describe('Edge Cases', () => { - it('SIMPLE_ERROR: should still return Error objects', async () => { - + it('SIMPLE_ERROR remains Error', async () => { try { - const res: Error = await client + await client .withTypeMapping({ [RESP_TYPES.SIMPLE_ERROR]: Error }) .hello(); - assert.ok(typeof res === 'object'); + + assert.fail('Expected error'); } catch (e) { assert.ok(e instanceof Error); } }); - it('NULL: should always remain null regardless of mapping', async () => { - const res: string | null = await client + it('NULL always remains null', async () => { + const res = await client .withTypeMapping({}) .get('missing-key-random-12345'); assert.strictEqual(res, null); }); - it('hGet: should infer string | null (fixing string | {})', async () => { - const res: string | null = await client + it('hGet infers string | null', async () => { + const res = await client .withTypeMapping({ [RESP_TYPES.BLOB_STRING]: String }) @@ -130,4 +127,4 @@ describe('Comprehensive RESP Type Mapping', () => { assert.ok(res === null || typeof res === 'string'); }); }); -}); \ No newline at end of file +}); From aa815d1759c9ad382f31e80e81fa50ab97e5fe4b Mon Sep 17 00:00:00 2001 From: "bramwelbarack89@gmail.com" Date: Fri, 30 Jan 2026 15:43:28 +0300 Subject: [PATCH 3/4] Updated tests --- packages/client/lib/RESP/types.spec.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/client/lib/RESP/types.spec.ts b/packages/client/lib/RESP/types.spec.ts index a7f3dd7ffa6..f2cc19f73b8 100644 --- a/packages/client/lib/RESP/types.spec.ts +++ b/packages/client/lib/RESP/types.spec.ts @@ -35,15 +35,7 @@ describe('Comprehensive RESP Type Mapping', () => { ); }); - it('DOUBLE: maps to number', async () => { - const res = await client - .withTypeMapping({ - [RESP_TYPES.DOUBLE]: Number - }) - .hello(); - assert.ok(res === null || typeof res === 'number'); - }); }); describe('Complex Strings', () => { From 276947881a8616f40a8c30a5a2a8f393ac6af9ce Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 3 Feb 2026 17:04:59 +0200 Subject: [PATCH 4/4] test: add DOUBLE type mapping test using ZINCRBY - Uses zIncrBy which actually returns a DoubleReply - Verifies RESP_TYPES.DOUBLE maps to Number correctly - Replaces previous test that incorrectly used hello() command --- packages/client/lib/RESP/types.spec.ts | 207 +++++++++++-------------- 1 file changed, 92 insertions(+), 115 deletions(-) diff --git a/packages/client/lib/RESP/types.spec.ts b/packages/client/lib/RESP/types.spec.ts index f2cc19f73b8..ce9693afc46 100644 --- a/packages/client/lib/RESP/types.spec.ts +++ b/packages/client/lib/RESP/types.spec.ts @@ -1,122 +1,99 @@ import { strict as assert } from 'node:assert'; -import { describe, it, beforeEach, afterEach } from 'mocha'; -import { createClient } from '../../index'; +import testUtils, { GLOBAL } from '../test-utils'; import { RESP_TYPES } from './decoder'; -describe('Comprehensive RESP Type Mapping', () => { - let client: any; - - beforeEach(async () => { - client = createClient(); - await client.connect(); - }); - - afterEach(async () => { - await client.destroy(); - }); - - describe('Scalar Primitives', () => { - it('INTEGER: EXISTS returns number (0|1)', async () => { - const res = await client.withTypeMapping({}).exists('some_key'); - assert.strictEqual(typeof res, 'number'); - }); - - it('BIG_NUMBER: maps to bigint when configured', async () => { - const res = await client +describe('RESP Type Mapping', () => { + testUtils.testWithClient('type mappings', async client => { + // Scalar Primitives + // INTEGER: EXISTS returns number (0|1) + const existsRes = await client.withTypeMapping({}).exists('some_key'); + assert.strictEqual(typeof existsRes, 'number'); + + // BIG_NUMBER: maps to bigint when configured + const bigNumRes = await client + .withTypeMapping({ + [RESP_TYPES.BIG_NUMBER]: BigInt + }) + .hello(); + assert.ok( + typeof bigNumRes === 'bigint' || + typeof bigNumRes === 'number' || + typeof bigNumRes === 'object' + ); + + // DOUBLE: maps to number when configured + // Use ZINCRBY which returns a DoubleReply + const doubleRes = await client + .withTypeMapping({ + [RESP_TYPES.DOUBLE]: Number + }) + .zIncrBy('zset-double-test', 1.5, 'member'); + assert.strictEqual(typeof doubleRes, 'number'); + assert.strictEqual(doubleRes, 1.5); + + // Complex Strings + // VERBATIM_STRING maps to string + const verbatimRes = await client + .withTypeMapping({ + [RESP_TYPES.VERBATIM_STRING]: String + }) + .get('key'); + assert.ok( + verbatimRes === null || + typeof verbatimRes === 'string' || + Buffer.isBuffer(verbatimRes) + ); + + // Recursive Collections + // ARRAY infers nested mapped types + const arrayRes = await client + .withTypeMapping({ + [RESP_TYPES.BLOB_STRING]: String + }) + .lRange('key', 0, -1); + assert.ok(Array.isArray(arrayRes)); + + // SET infers Set or array + const setRes = await client + .withTypeMapping({ + [RESP_TYPES.BLOB_STRING]: String + }) + .sMembers('key'); + assert.ok(setRes instanceof Set || Array.isArray(setRes)); + + // MAP infers Map or object + const mapRes = await client + .withTypeMapping({ + [RESP_TYPES.BLOB_STRING]: String + }) + .hGetAll('key'); + assert.ok(mapRes instanceof Map || typeof mapRes === 'object'); + + // Edge Cases + // SIMPLE_ERROR remains Error + try { + await client .withTypeMapping({ - [RESP_TYPES.BIG_NUMBER]: BigInt + [RESP_TYPES.SIMPLE_ERROR]: Error }) .hello(); - - assert.ok( - typeof res === 'bigint' || - typeof res === 'number' || - typeof res === 'object' - ); - }); - - - }); - - describe('Complex Strings', () => { - it('VERBATIM_STRING maps to string', async () => { - const res = await client - .withTypeMapping({ - [RESP_TYPES.VERBATIM_STRING]: String - }) - .get('key'); - - assert.ok( - res === null || - typeof res === 'string' || - Buffer.isBuffer(res) - ); - }); - }); - - describe('Recursive Collections', () => { - it('ARRAY infers nested mapped types', async () => { - const res = await client - .withTypeMapping({ - [RESP_TYPES.BLOB_STRING]: String - }) - .lRange('key', 0, -1); - - assert.ok(Array.isArray(res)); - }); - - it('SET infers Set or array', async () => { - const res = await client - .withTypeMapping({ - [RESP_TYPES.BLOB_STRING]: String - }) - .sMembers('key'); - - assert.ok(res instanceof Set || Array.isArray(res)); - }); - - it('MAP infers Map or object', async () => { - const res = await client - .withTypeMapping({ - [RESP_TYPES.BLOB_STRING]: String - }) - .hGetAll('key'); - - assert.ok(res instanceof Map || typeof res === 'object'); - }); - }); - - describe('Edge Cases', () => { - it('SIMPLE_ERROR remains Error', async () => { - try { - await client - .withTypeMapping({ - [RESP_TYPES.SIMPLE_ERROR]: Error - }) - .hello(); - - assert.fail('Expected error'); - } catch (e) { - assert.ok(e instanceof Error); - } - }); - - it('NULL always remains null', async () => { - const res = await client - .withTypeMapping({}) - .get('missing-key-random-12345'); - - assert.strictEqual(res, null); - }); - - it('hGet infers string | null', async () => { - const res = await client - .withTypeMapping({ - [RESP_TYPES.BLOB_STRING]: String - }) - .hGet('foo', 'bar'); - - assert.ok(res === null || typeof res === 'string'); - }); - }); + assert.fail('Expected error'); + } catch (e) { + assert.ok(e instanceof Error); + } + + // NULL always remains null + const nullRes = await client + .withTypeMapping({}) + .get('missing-key-random-12345'); + assert.strictEqual(nullRes, null); + + // hGet infers string | null + const hGetRes = await client + .withTypeMapping({ + [RESP_TYPES.BLOB_STRING]: String + }) + .hGet('foo', 'bar'); + assert.ok(hGetRes === null || typeof hGetRes === 'string'); + }, GLOBAL.SERVERS.OPEN); });