Skip to content

Commit 509014d

Browse files
committed
Add replacer option to stringify()
Closes #203
1 parent ab603a8 commit 509014d

File tree

4 files changed

+274
-2
lines changed

4 files changed

+274
-2
lines changed

base.d.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,51 @@ export type StringifyOptions = {
535535
```
536536
*/
537537
readonly skipEmptyString?: boolean;
538+
539+
/**
540+
A function that transforms key-value pairs before stringification.
541+
542+
Similar to the `replacer` parameter of `JSON.stringify()`, this function is called for each key-value pair and can be used to transform values before they are stringified.
543+
544+
The function receives the key and value, and should return the transformed value. Returning `undefined` will omit the key-value pair from the resulting query string.
545+
546+
This is useful for custom serialization of non-primitive types like `Date`:
547+
548+
@example
549+
```
550+
import queryString from 'query-string';
551+
552+
queryString.stringify({
553+
date: new Date('2024-01-15T10:30:00Z'),
554+
name: 'John'
555+
}, {
556+
replacer: (key, value) => {
557+
if (value instanceof Date) {
558+
return value.toISOString();
559+
}
560+
561+
return value;
562+
}
563+
});
564+
//=> 'date=2024-01-15T10%3A30%3A00.000Z&name=John'
565+
```
566+
567+
@example
568+
```
569+
import queryString from 'query-string';
570+
571+
// Omit keys with null values using replacer instead of skipNull
572+
queryString.stringify({
573+
a: 1,
574+
b: null,
575+
c: 3
576+
}, {
577+
replacer: (key, value) => value === null ? undefined : value
578+
});
579+
//=> 'a=1&c=3'
580+
```
581+
*/
582+
readonly replacer?: (key: string, value: unknown) => unknown;
538583
};
539584

540585
/**

base.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,17 @@ export function stringify(object, options) {
463463
}
464464

465465
return keys.map(key => {
466-
const value = object[key];
466+
let value = object[key];
467+
468+
// Apply replacer function if provided
469+
if (options.replacer) {
470+
value = options.replacer(key, value);
471+
472+
// If replacer returns undefined, skip this key
473+
if (value === undefined) {
474+
return '';
475+
}
476+
}
467477

468478
if (value === undefined) {
469479
return '';
@@ -478,7 +488,16 @@ export function stringify(object, options) {
478488
return encode(key, options) + '[]';
479489
}
480490

481-
return value
491+
// Apply replacer to array elements if provided
492+
// Note: We don't re-apply replacer to the array itself, only to elements
493+
let processedArray = value;
494+
if (options.replacer) {
495+
processedArray = value.map((item, index) =>
496+
options.replacer(`${key}[${index}]`, item),
497+
).filter(item => item !== undefined);
498+
}
499+
500+
return processedArray
482501
.reduce(formatter(key), [])
483502
.join('&');
484503
}

readme.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,50 @@ queryString.stringify({a: '', b: ''}, {
522522
//=> ''
523523
```
524524
525+
##### replacer
526+
527+
A function that transforms key-value pairs before stringification.
528+
529+
Type: `function`\
530+
Default: `undefined`
531+
532+
Similar to the `replacer` parameter of `JSON.stringify()`, this function is called for each key-value pair and can be used to transform values before they are stringified. The function receives the key and value, and should return the transformed value. Returning `undefined` will omit the key-value pair from the resulting query string.
533+
534+
This is useful for custom serialization of non-primitive types like `Date`:
535+
536+
```js
537+
import queryString from 'query-string';
538+
539+
queryString.stringify({
540+
date: new Date('2024-01-15T10:30:00Z'),
541+
name: 'John'
542+
}, {
543+
replacer: (key, value) => {
544+
if (value instanceof Date) {
545+
return value.toISOString();
546+
}
547+
548+
return value;
549+
}
550+
});
551+
//=> 'date=2024-01-15T10%3A30%3A00.000Z&name=John'
552+
```
553+
554+
You can also use it to filter out keys:
555+
556+
```js
557+
import queryString from 'query-string';
558+
559+
queryString.stringify({
560+
a: 1,
561+
b: null,
562+
c: 3
563+
}, {
564+
replacer: (key, value) => value === null ? undefined : value
565+
});
566+
//=> 'a=1&c=3'
567+
```
568+
525569
### .extract(string)
526570

527571
Extract a query string from a URL that can be passed into `.parse()`.

test/stringify.js

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,167 @@ test('array stringify representation with (:list) colon-list-separator with null
431431
arrayFormat: 'colon-list-separator',
432432
}), 'bar:list=one&bar:list=&foo');
433433
});
434+
435+
test('replacer option transforms Date objects to ISO strings', t => {
436+
const date = new Date('2024-01-15T10:30:00.000Z');
437+
t.is(queryString.stringify({
438+
name: 'John',
439+
created: date,
440+
}, {
441+
replacer(key, value) {
442+
if (value instanceof Date) {
443+
return value.toISOString();
444+
}
445+
446+
return value;
447+
},
448+
}), 'created=2024-01-15T10%3A30%3A00.000Z&name=John');
449+
});
450+
451+
test('replacer option can omit null values', t => {
452+
t.is(queryString.stringify({
453+
a: 1,
454+
b: null,
455+
c: 3,
456+
}, {
457+
replacer: (key, value) => value === null ? undefined : value,
458+
}), 'a=1&c=3');
459+
});
460+
461+
test('replacer option can transform specific keys', t => {
462+
t.is(queryString.stringify({
463+
price: 10,
464+
quantity: 5,
465+
}, {
466+
replacer(key, value) {
467+
if (key === 'price' && typeof value === 'number') {
468+
return `$${value}`;
469+
}
470+
471+
return value;
472+
},
473+
}), 'price=%2410&quantity=5');
474+
});
475+
476+
test('replacer option can filter array elements', t => {
477+
t.is(queryString.stringify({
478+
tags: ['one', 'two', 'three'],
479+
}, {
480+
replacer(key, value) {
481+
if (key.startsWith('tags[') && value === 'two') {
482+
return undefined; // Skip 'two'
483+
}
484+
485+
return value;
486+
},
487+
}), 'tags=one&tags=three');
488+
});
489+
490+
test('replacer option returning undefined for all values results in empty string', t => {
491+
t.is(queryString.stringify({
492+
a: 1,
493+
b: 2,
494+
}, {
495+
replacer: () => undefined,
496+
}), '');
497+
});
498+
499+
test('replacer option can transform array to string', t => {
500+
t.is(queryString.stringify({
501+
tags: ['one', 'two', 'three'],
502+
}, {
503+
replacer(key, value) {
504+
if (Array.isArray(value)) {
505+
return value.join('|');
506+
}
507+
508+
return value;
509+
},
510+
}), 'tags=one%7Ctwo%7Cthree');
511+
});
512+
513+
test('replacer option works with bracket array format', t => {
514+
t.is(queryString.stringify({
515+
items: [1, 2, 3],
516+
}, {
517+
arrayFormat: 'bracket',
518+
replacer(key, value) {
519+
if (typeof value === 'number') {
520+
return value * 10;
521+
}
522+
523+
return value;
524+
},
525+
}), 'items[]=10&items[]=20&items[]=30');
526+
});
527+
528+
test('replacer option handles edge cases correctly', t => {
529+
t.is(queryString.stringify({
530+
undefinedValue: undefined,
531+
nullValue: null,
532+
empty: '',
533+
zero: 0,
534+
false: false,
535+
}, {
536+
replacer: (key, value) => value,
537+
}), 'empty=&false=false&nullValue&zero=0');
538+
});
539+
540+
test('replacer option can handle Symbol without crashing', t => {
541+
const symbol = Symbol('test');
542+
t.is(queryString.stringify({
543+
a: 1,
544+
b: symbol,
545+
}, {
546+
replacer(key, value) {
547+
if (typeof value === 'symbol') {
548+
return 'symbol-value';
549+
}
550+
551+
return value;
552+
},
553+
}), 'a=1&b=symbol-value');
554+
});
555+
556+
test('replacer option works with comma array format', t => {
557+
t.is(queryString.stringify({
558+
colors: ['red', 'green', 'blue'],
559+
}, {
560+
arrayFormat: 'comma',
561+
replacer(key, value) {
562+
if (key.startsWith('colors[') && value === 'green') {
563+
return 'GREEN';
564+
}
565+
566+
return value;
567+
},
568+
}), 'colors=red,GREEN,blue');
569+
});
570+
571+
test('replacer option is called with correct keys for index array format', t => {
572+
const replacerKeys = [];
573+
queryString.stringify({
574+
items: ['x', 'y'],
575+
}, {
576+
arrayFormat: 'index',
577+
replacer(key, value) {
578+
replacerKeys.push(key);
579+
return value;
580+
},
581+
});
582+
t.deepEqual(replacerKeys, ['items', 'items[0]', 'items[1]']);
583+
});
584+
585+
test('replacer option can transform objects to JSON strings', t => {
586+
t.is(queryString.stringify({
587+
data: {nested: 'value'},
588+
}, {
589+
replacer(key, value) {
590+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
591+
return JSON.stringify(value);
592+
}
593+
594+
return value;
595+
},
596+
}), 'data=%7B%22nested%22%3A%22value%22%7D');
597+
});

0 commit comments

Comments
 (0)