Repository: sindresorhus/query-string
Pull request #385 (2024-05-25 22:25:18)

feat: implement types schema option parameter
Hi @sindresorhus, I decided to pick up this feature request. I used your comments on this PR: https://github.com/sindresorhus/query-string/pull/249 to help guide me on implementation requirements. 

Resolves https://github.com/sindresorhus/query-string/issues/210
related to https://github.com/sindresorhus/query-string/issues/201

Let me know if you have any feedback!

sindresorhus: Thanks for working on this
ScottEnock: Thanks for the feedback @sindresorhus! I've done a second pass of the doc comments and tried to get it as concise and clear as possible. Let me know if there's any areas you think need reworking 💪
sindresorhus: It also needs to be added to the readme. I would wait until the TS docs are done. It should be as close to the TS docs as possible.
ScottEnock: Thanks for taking another look at my changes @sindresorhus :muscle: 

I've made the corrections, and have added additional context in the TS Docs. Let me know if they look good to you, and I'll get the readme updated accordingly. 
sindresorhus: > I've made the corrections, and have added additional context in the TS Docs. Let me know if they look good to you, and I'll get the readme updated accordingly.

Looks good 👍
ScottEnock: That's great @sindresorhus , I've updated the `readme.md` and have adjusted it slightly to work with the different format of the file. Let me know what you think! 

Patch:
diff --git a/base.d.ts b/base.d.ts
index 4a2a3b0..6bedc6d 100644
--- a/base.d.ts
+++ b/base.d.ts
@@ -87,7 +87,14 @@ export type ParseOptions = {
 		//=> {foo: ['1', '2', '3']}
 		```
 	*/
-	readonly arrayFormat?: 'bracket' | 'index' | 'comma' | 'separator' | 'bracket-separator' | 'colon-list-separator' | 'none';
+	readonly arrayFormat?:
+	| 'bracket'
+	| 'index'
+	| 'comma'
+	| 'separator'
+	| 'bracket-separator'
+	| 'colon-list-separator'
+	| 'none';
 
 	/**
 	The character used to separate array elements when using `{arrayFormat: 'separator'}`.
@@ -169,6 +176,108 @@ export type ParseOptions = {
 	```
 	*/
 	readonly parseFragmentIdentifier?: boolean;
+
+	/**
+	Specify a pre-defined schema to be used when parsing values. The types specified will take precedence over options such as: `parseNumber`, `parseBooleans`, and `arrayFormat`.
+
+	Use this feature to override the type of a value. This can be useful when the type is ambiguous such as a phone number (see example 1 and 2).
+
+	It is possible to provide a custom function as the parameter type. The parameter's value will equal the function's return value (see example 4).
+
+	NOTE: Array types (`string[]` and `number[]`) will have no effect if `arrayFormat` is set to `none` (see example 5).
+
+	@default {}
+
+	@example
+	Parse `phoneNumber` as a string, overriding the `parseNumber` option:
+	```
+	import queryString from 'query-string';
+
+	queryString.parse('?phoneNumber=%2B380951234567&id=1', {
+		parseNumbers: true,
+		types: {
+			phoneNumber: 'string',
+		}
+	});
+	//=> {phoneNumber: '+380951234567', id: 1}
+	```
+
+	@example
+	Parse `items` as an array of strings, overriding the `parseNumber` option:
+	```
+	import queryString from 'query-string';
+
+	queryString.parse('?age=20&items=1%2C2%2C3', {
+		parseNumber: true,
+		types: {
+			items: 'string[]',
+		}
+	});
+	//=> {age: 20, items: ['1', '2', '3']}
+	```
+
+	@example
+	Parse `age` as a number, even when `parseNumber` is false:
+	```
+	import queryString from 'query-string';
+
+	queryString.parse('?age=20&id=01234&zipcode=90210', {
+		types: {
+			age: 'number',
+		}
+	});
+	//=> {age: 20, id: '01234', zipcode: '90210 }
+	```
+
+	@example
+	Parse `age` using a custom value parser:
+	```
+	import queryString from 'query-string';
+
+	queryString.parse('?age=20&id=01234&zipcode=90210', {
+		types: {
+			age: (value) => value * 2,
+		}
+	});
+	//=> {age: 40, id: '01234', zipcode: '90210 }
+	```
+
+	@example
+	Array types will have no effect when `arrayFormat` is set to `none`
+	```
+	queryString.parse('ids=001%2C002%2C003&foods=apple%2Corange%2Cmango', {
+		arrayFormat: 'none',
+		types: {
+			ids: 'number[]',
+			foods: 'string[]',
+		},
+	}
+	//=> {ids:'001,002,003', foods:'apple,orange,mango'}
+	```
+
+	@example
+	Parse a query utilizing all types:
+	```
+	import queryString from 'query-string';
+
+	queryString.parse('?ids=001%2C002%2C003&items=1%2C2%2C3&price=22%2E00&numbers=1%2C2%2C3&double=5&number=20', {
+		arrayFormat: 'comma',
+		types: {
+			ids: 'string',
+			items: 'string[]',
+			price: 'string',
+			numbers: 'number[]',
+			double: (value) => value * 2,
+			number: 'number',
+		},
+	});
+	//=> {ids: '001,002,003', items: ['1', '2', '3'], price: '22.00', numbers: [1, 2, 3], double: 10, number: 20}
+	```
+	*/
+	readonly types?: Record<
+	string,
+	'number' | 'string' | 'string[]' | 'number[]' | ((value: string) => unknown)
+	>;
 };
 
 // eslint-disable-next-line @typescript-eslint/ban-types
diff --git a/base.js b/base.js
index dc3f742..ccbdb6e 100644
--- a/base.js
+++ b/base.js
@@ -1,6 +1,6 @@
 import decodeComponent from 'decode-uri-component';
-import splitOnFirst from 'split-on-first';
 import {includeKeys} from 'filter-obj';
+import splitOnFirst from 'split-on-first';
 
 const isNullOrUndefined = value => value === null || value === undefined;
 
@@ -300,11 +300,25 @@ function getHash(url) {
 	return hash;
 }
 
-function parseValue(value, options) {
+function parseValue(value, options, type) {
+	if (type === 'string' && typeof value === 'string') {
+		return value;
+	}
+
+	if (typeof type === 'function' && typeof value === 'string') {
+		return type(value);
+	}
+
+	if (options.parseBooleans && value !== null && (value.toLowerCase() === 'true' || value.toLowerCase() === 'false')) {
+		return value.toLowerCase() === 'true';
+	}
+
+	if (type === 'number' && !Number.isNaN(Number(value)) && (typeof value === 'string' && value.trim() !== '')) {
+		return Number(value);
+	}
+
 	if (options.parseNumbers && !Number.isNaN(Number(value)) && (typeof value === 'string' && value.trim() !== '')) {
-		value = Number(value);
-	} else if (options.parseBooleans && value !== null && (value.toLowerCase() === 'true' || value.toLowerCase() === 'false')) {
-		value = value.toLowerCase() === 'true';
+		return Number(value);
 	}
 
 	return value;
@@ -328,6 +342,7 @@ export function parse(query, options) {
 		arrayFormatSeparator: ',',
 		parseNumbers: false,
 		parseBooleans: false,
+		types: Object.create(null),
 		...options,
 	};
 
@@ -368,12 +383,15 @@ export function parse(query, options) {
 	}
 
 	for (const [key, value] of Object.entries(returnValue)) {
-		if (typeof value === 'object' && value !== null) {
+		if (typeof value === 'object' && value !== null && options.types[key] !== 'string') {
 			for (const [key2, value2] of Object.entries(value)) {
-				value[key2] = parseValue(value2, options);
+				const type = options.types[key] ? options.types[key].replace('[]', '') : undefined;
+				value[key2] = parseValue(value2, options, type);
 			}
+		} else if (typeof value === 'object' && value !== null && options.types[key] === 'string') {
+			returnValue[key] = Object.values(value).join(options.arrayFormatSeparator);
 		} else {
-			returnValue[key] = parseValue(value, options);
+			returnValue[key] = parseValue(value, options, options.types[key]);
 		}
 	}
 
diff --git a/readme.md b/readme.md
index 2bf52a1..111f980 100644
--- a/readme.md
+++ b/readme.md
@@ -224,6 +224,114 @@ queryString.parse('foo=true', {parseBooleans: true});
 
 Parse the value as a boolean type instead of string type if it's a boolean.
 
+##### types
+
+Type: `object`\
+Default: `{}`
+
+Specify a pre-defined schema to be used when parsing values. The types specified will take precedence over options such as: `parseNumber`, `parseBooleans`, and `arrayFormat`.
+
+Use this feature to override the type of a value. This can be useful when the type is ambiguous such as a phone number.
+
+It is possible to provide a custom function as the parameter type. The parameter's value will equal the function's return value.
+
+Supported Types:
+
+- `'string'`: Parse `phoneNumber` as a string (overriding the `parseNumber` option):
+
+```js
+import queryString from 'query-string';
+
+queryString.parse('?phoneNumber=%2B380951234567&id=1', {
+	parseNumbers: true,
+	types: {
+		phoneNumber: 'string',
+	}
+});
+//=> {phoneNumber: '+380951234567', id: 1}
+```
+
+- `'number'`:  Parse `age` as a number (even when `parseNumber` is false):
+
+```js
+import queryString from 'query-string';
+
+queryString.parse('?age=20&id=01234&zipcode=90210', {
+	types: {
+		age: 'number',
+	}
+});
+//=> {age: 20, id: '01234', zipcode: '90210 }
+```
+
+- `'string[]'`:  Parse `items` as an array of strings (overriding the `parseNumber` option):
+
+```js
+import queryString from 'query-string';
+
+queryString.parse('?age=20&items=1%2C2%2C3', {
+	parseNumber: true,
+	types: {
+		items: 'string[]',
+	}
+});
+//=> {age: 20, items: ['1', '2', '3']}
+```
+
+- `'number[]'`:  Parse `items` as an array of numbers (even when `parseNumber` is false):
+
+```js
+import queryString from 'query-string';
+
+queryString.parse('?age=20&items=1%2C2%2C3', {
+	types: {
+		items: 'number[]',
+	}
+});
+//=> {age: '20', items: [1, 2, 3]}
+```
+
+- `'Function'`: Provide a custom function as the parameter type. The parameter's value will equal the function's return value.
+
+```js
+import queryString from 'query-string';
+
+queryString.parse('?age=20&id=01234&zipcode=90210', {
+	types: {
+		age: (value) => value * 2,
+	}
+});
+//=> {age: 40, id: '01234', zipcode: '90210 }
+```
+
+NOTE: Array types (`string[]` and `number[]`) will have no effect if `arrayFormat` is set to `none`.
+
+```js
+queryString.parse('ids=001%2C002%2C003&foods=apple%2Corange%2Cmango', {
+	arrayFormat: 'none',
+	types: {
+		ids: 'number[]',
+		foods: 'string[]',
+	},
+}
+//=> {ids:'001,002,003', foods:'apple,orange,mango'}
+```
+
+###### Function
+
+```js
+import queryString from 'query-string';
+
+queryString.parse('?age=20&id=01234&zipcode=90210', {
+	types: {
+		age: (value) => value * 2,
+	}
+});
+//=> {age: 40, id: '01234', zipcode: '90210 }
+```
+
+Parse the value as a boolean type instead of string type if it's a boolean.
+
 ### .stringify(object, options?)
 
 Stringify an object into a query string and sorting the keys.


Test patch:
diff --git a/test/parse.js b/test/parse.js
index 17de629..3409255 100644
--- a/test/parse.js
+++ b/test/parse.js
@@ -404,3 +404,130 @@ test('query strings having (:list) colon-list-separator arrays', t => {
 test('query strings having (:list) colon-list-separator arrays including null values', t => {
 	t.deepEqual(queryString.parse('bar:list=one&bar:list=two&foo', {arrayFormat: 'colon-list-separator'}), {bar: ['one', 'two'], foo: null});
 });
+
+test('types option: can override a parsed number to be a string ', t => {
+	t.deepEqual(queryString.parse('phoneNumber=%2B380951234567', {
+		parseNumbers: true,
+		types: {
+			phoneNumber: 'string',
+		},
+	}), {phoneNumber: '+380951234567'});
+});
+
+test('types option: can override a parsed boolean value to be a string', t => {
+	t.deepEqual(queryString.parse('question=true', {
+		parseBooleans: true,
+		types: {
+			question: 'string',
+		},
+	}), {
+		question: 'true',
+	});
+});
+
+test('types option: can override parsed numbers arrays to be string[]', t => {
+	t.deepEqual(queryString.parse('ids=999%2C998%2C997&items=1%2C2%2C3', {
+		arrayFormat: 'comma',
+		parseNumbers: true,
+		types: {
+			ids: 'string[]',
+		},
+	}), {
+		ids: ['999', '998', '997'],
+		items: [1, 2, 3],
+	});
+});
+
+test('types option: can override string arrays to be number[]', t => {
+	t.deepEqual(queryString.parse('ids=001%2C002%2C003&items=1%2C2%2C3', {
+		arrayFormat: 'comma',
+		types: {
+			ids: 'number[]',
+		},
+	}), {
+		ids: [1, 2, 3],
+		items: ['1', '2', '3'],
+	});
+});
+
+test('types option: can override an array to be string', t => {
+	t.deepEqual(queryString.parse('ids=001%2C002%2C003&items=1%2C2%2C3', {
+		arrayFormat: 'comma',
+		parseNumbers: true,
+		types: {
+			ids: 'string',
+		},
+	}), {
+		ids: '001,002,003',
+		items: [1, 2, 3],
+	});
+});
+
+test('types option: can override a separator array to be string ', t => {
+	t.deepEqual(queryString.parse('ids=001|002|003&items=1|2|3', {
+		arrayFormat: 'separator',
+		arrayFormatSeparator: '|',
+		parseNumbers: true,
+		types: {
+			ids: 'string',
+		},
+	}), {
+		ids: '001|002|003',
+		items: [1, 2, 3],
+	});
+});
+
+test('types option: when value is not of specified type, it will safely parse the value as string', t => {
+	t.deepEqual(queryString.parse('id=example', {
+		types: {
+			id: 'number',
+		},
+	}), {
+		id: 'example',
+	});
+});
+
+test('types option: array types will have no effect if arrayFormat is set to "none"', t => {
+	t.deepEqual(queryString.parse('ids=001%2C002%2C003&foods=apple%2Corange%2Cmango', {
+		arrayFormat: 'none',
+		types: {
+			ids: 'number[]',
+			foods: 'string[]',
+		},
+	}), {
+		ids: '001,002,003',
+		foods: 'apple,orange,mango',
+	});
+});
+
+test('types option: will parse the value as number if specified in type but parseNumbers is false', t => {
+	t.deepEqual(queryString.parse('id=123', {
+		arrayFormat: 'comma',
+		types: {
+			id: 'number',
+		},
+	}), {
+		id: 123,
+	});
+});
+
+test('types option: all supported types work in conjunction with one another', t => {
+	t.deepEqual(queryString.parse('ids=001%2C002%2C003&items=1%2C2%2C3&price=22%2E00&numbers=1%2C2%2C3&double=5&number=20', {
+		arrayFormat: 'comma',
+		types: {
+			ids: 'string',
+			items: 'string[]',
+			price: 'string',
+			numbers: 'number[]',
+			double: value => value * 2,
+			number: 'number',
+		},
+	}), {
+		ids: '001,002,003',
+		items: ['1', '2', '3'],
+		price: '22.00',
+		numbers: [1, 2, 3],
+		double: 10,
+		number: 20,
+	});
+});