From 148f45202513225f7adf2c59028a1c712ebd4c67 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:25:10 +0000 Subject: [PATCH 1/2] feat: Add strict parsing mode as the default Add a strict option to parseUrlSearchParams, defaulting to true. In strict mode, only the expected output of @seamapi/url-search-params-serializer is parsed, making the parser a true inverse of the serializer: arrays use only the repeated format, array values may contain commas and are never split, the bracket format throws UnparseableSearchParamError, only the strings true and false parse as booleans, and whitespace is never trimmed or treated as empty. Pass strict: false for generous parsing, which preserves the previous behavior: comma and bracket array formats, generous boolean values, and whitespace trimming, at the cost of array values not containing commas. BREAKING CHANGE: Generous parsing is no longer the default; pass strict: false to parseUrlSearchParams to keep the previous behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015jWzL9LC7Q6bTGaXDoKq4z --- README.md | 71 ++++++++++++++++----- src/lib/parse.ts | 113 ++++++++++++++++++++++++++-------- test/bijection.test.ts | 19 +++++- test/generous-parsing.test.ts | 105 ++++++++++++++++--------------- test/strict-parsing.test.ts | 113 ++++++++++++++++++++++++++++++++++ test/zod-v4.test.ts | 26 +++++--- 6 files changed, 346 insertions(+), 101 deletions(-) create mode 100644 test/strict-parsing.test.ts diff --git a/README.md b/README.md index 10828c2..ef3616d 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,27 @@ This parser may be used as a true inverse operation to [@seamapi/url-search-para [@url-search-params-serializer]: https://github.com/seamapi/url-search-params-serializer +### Strict Parsing + +By default, or when passing `strict: true`, +the parser only parses the expected output of [@url-search-params-serializer], +making the parser a true inverse of the serializer: + +- Parses `z.array()` only in the repeated format `foo=1&foo=2`. + - Array values may contain a `,` and are never split, + e.g., `foo=a,b&foo=c` is parsed as `['a,b', 'c']`. + - The bracket format `foo[]=1` throws an `UnparseableSearchParamError`, + since the serializer never outputs it. +- For `z.boolean()`, only the strings `true` and `false` are parsed. +- Whitespace is significant and is never trimmed: + only a completely empty value is parsed as `null` + (or as the empty array for `z.array()`). + ### Generous Parsing -This parser provides strict compatibility with the serialization format of [@url-search-params-serializer]. -However, some additional input cases are handled: +When passing `strict: false`, additional input cases are handled +at the cost of some limitations, e.g., +array string values containing a `,` are not supported: - For `z.number()`, `z.boolean()`, `z.date()`, `z.object()`, and `z.record()`, whitespace only values are parsed as `null`. @@ -25,6 +42,17 @@ However, some additional input cases are handled: `true`, `True`, `TRUE`, `yes`, `Yes`, `YES`, and `1`. - For `z.boolean()`, the following values are parsed as `false`: `false`, `False`, `FALSE`, `no`, `No`, `NO`, and `0`. +- Parses `z.array()` in the following formats. + In order to support unambiguous parsing, array string values + containing a `,` are not supported. + - `foo=1&foo=2` + - `foo[]=1&foo[]=2` + - `foo=1,2` + +### Parsing in Both Modes + +These rules apply in strict and generous mode alike: + - For `z.number()`, `z.boolean()`, and `z.date()`, values that cannot be parsed as the expected type are passed through unchanged as strings, e.g., `foo=a` is parsed as `'a'` for `z.number()`. @@ -34,12 +62,6 @@ However, some additional input cases are handled: Whitespace is significant and is never trimmed for `z.string()`. - For `z.object()` and `z.record()`, a non-empty value is passed through unchanged as a string, e.g., `foo=a` is parsed as `'a'`. -- Parses `z.array()` in the following formats. - In order to support unambiguous parsing, array string values - containing a `,` are not supported. - - `foo=1&foo=2` - - `foo[]=1&foo[]=2` - - `foo=1,2` - Search params not present in the schema are ignored. ### Unparseable Search Params @@ -48,19 +70,23 @@ Some inputs are ambiguous and cannot be parsed unambiguously. These throw an `UnparseableSearchParamError`: - A non-array param with repeated values, e.g., `foo=1&foo=2` for `z.number()`. -- An array param that mixes array formats, - e.g., `foo=1&foo[]=2` or `foo=1,2&foo=3`. -- An array param that repeats a value containing a `,`, - e.g., `foo=a,b&foo=c,d`. -- An array param using the bracket format with a value containing a `,`, - e.g., `foo[]=a,b`. - An array param that mixes empty values with other values, - e.g., `foo=&foo=1`, `foo=&foo=`, or `foo=a,,b`. + e.g., `foo=&foo=1` or `foo=&foo=`. - An object or record param that conflicts with its own nested params, e.g., `foo.bar=&foo.bar.a=1`, since this would be a null object containing a value. - A param nested inside a record param, e.g., `foo.a.b=1` for `z.record(z.string(), z.number())`. +- In strict mode, an array param using the bracket format, e.g., `foo[]=1`. +- In generous mode (`strict: false`): + - An array param that mixes array formats, + e.g., `foo=1&foo[]=2` or `foo=1,2&foo=3`. + - An array param that repeats a value containing a `,`, + e.g., `foo=a,b&foo=c,d`. + - An array param using the bracket format with a value containing a `,`, + e.g., `foo[]=a,b`. + - An array param using the comma format with empty values, + e.g., `foo=a,,b`. Schemas that do not obey these rules throw an `UnparseableSchemaError`. @@ -132,6 +158,21 @@ parseUrlSearchParams( ) // => { name: 'Dax', age: 27, isAdmin: true, tags: ['cars', 'planes'] } ``` +Pass `strict: false` to enable [generous parsing](#generous-parsing), +which accepts additional input formats +at the cost of no longer being a true inverse of the serializer. + +```ts +parseUrlSearchParams( + 'isAdmin=yes&tags=cars,planes', + z.object({ + isAdmin: z.boolean(), + tags: z.array(z.string()), + }), + { strict: false }, +) // => { isAdmin: true, tags: ['cars', 'planes'] } +``` + This parser does not validate its output: pass the parsed params to the schema to both validate and type them. diff --git a/src/lib/parse.ts b/src/lib/parse.ts index 0946b99..16dda5d 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -10,6 +10,11 @@ import { // Value types that are parsed from a single search param value. type LeafType = PrimitiveType | 'null' +// How array values containing a comma are handled: +// split into the array (the comma array format), +// rejected as unparseable, or kept verbatim. +type CommaHandling = 'split' | 'reject' | 'verbatim' + const arrayElementTypes: Partial> = { string_array: 'string', number_array: 'number', @@ -23,16 +28,31 @@ const recordElementTypes: Partial> = { date_record: 'date', } +export interface ParseUrlSearchParamsOptions { + /** + * When true, the default, only parse the expected output of + * @seamapi/url-search-params-serializer, making the parser + * a true inverse of the serializer. + * When false, enable generous parsing: additional input formats + * are accepted, at the cost of some limitations, + * e.g., array values may not contain a comma. + */ + strict?: boolean +} + export const parseUrlSearchParams = ( query: URLSearchParams | string, schema: ZodSchema, + options: ParseUrlSearchParamsOptions = {}, ): Record => { + const { strict = true } = options + const searchParams = typeof query === 'string' ? new URLSearchParams(query) : query const paramSchema = zodSchemaToParamSchema(schema) - return parseFromParamSchema(searchParams, paramSchema, []) as Record< + return parseFromParamSchema(searchParams, paramSchema, [], strict) as Record< string, unknown > @@ -42,9 +62,10 @@ const parseFromParamSchema = ( searchParams: URLSearchParams, node: ParamSchema | ValueType, path: string[], + strict: boolean, ): unknown => { if (typeof node === 'string') { - return parseValueType(searchParams, node, path) + return parseValueType(searchParams, node, path, strict) } const name = path.join('.') @@ -53,13 +74,13 @@ const parseFromParamSchema = ( // e.g., foo= for the schema z.object({ foo: z.object({ bar: z.string() }) }). if (path.length > 0 && searchParams.has(name)) { assertNoNestedParams(searchParams, name) - return parseNestedValue(searchParams, name) + return parseNestedValue(searchParams, name, strict) } const entries = Object.entries(node).reduce>( (acc, [k, v]) => [ ...acc, - [k, parseFromParamSchema(searchParams, v, [...path, k])], + [k, parseFromParamSchema(searchParams, v, [...path, k], strict)], ], [], ) @@ -71,6 +92,7 @@ const parseValueType = ( searchParams: URLSearchParams, type: ValueType, path: string[], + strict: boolean, ): unknown => { const name = path.join('.') @@ -79,12 +101,12 @@ const parseValueType = ( const arrayElementType = arrayElementTypes[type] if (arrayElementType != null) { - return parseArrayParam(searchParams, name, arrayElementType) + return parseArrayParam(searchParams, name, arrayElementType, strict) } const recordElementType = recordElementTypes[type] if (recordElementType != null) { - return parseRecordParam(searchParams, name, recordElementType) + return parseRecordParam(searchParams, name, recordElementType, strict) } const values = searchParams.getAll(name) @@ -99,18 +121,28 @@ const parseValueType = ( ) } - return parseLeaf(value, type as LeafType) + return parseLeaf(value, type as LeafType, strict) } const parseArrayParam = ( searchParams: URLSearchParams, name: string, elementType: LeafType, + strict: boolean, ): unknown => { const repeatedValues = searchParams.getAll(name) const bracketName = `${name}[]` const bracketValues = searchParams.getAll(bracketName) + if (strict && bracketValues.length > 0) { + throw new UnparseableSearchParamError( + bracketName, + 'uses the bracket array format, ' + + 'which is never output by the serializer ' + + 'and only parsed when strict is false', + ) + } + if (repeatedValues.length > 0 && bracketValues.length > 0) { throw new UnparseableSearchParamError( name, @@ -119,24 +151,30 @@ const parseArrayParam = ( } if (bracketValues.length > 0) { - return parseArrayValues(bracketName, bracketValues, elementType, false) + return parseArrayValues(bracketName, bracketValues, elementType, { + commaHandling: 'reject', + strict, + }) } if (repeatedValues.length === 0) return undefined - return parseArrayValues(name, repeatedValues, elementType, true) + return parseArrayValues(name, repeatedValues, elementType, { + commaHandling: strict ? 'verbatim' : 'split', + strict, + }) } const parseArrayValues = ( name: string, values: string[], elementType: LeafType, - allowCommaFormat: boolean, + { commaHandling, strict }: { commaHandling: CommaHandling; strict: boolean }, ): unknown[] => { // The serialization of the empty array is a single empty value. - if (values.length === 1 && isBlank(values[0] ?? '')) return [] + if (values.length === 1 && isEmpty(values[0] ?? '', strict)) return [] - if (values.some(isBlank)) { + if (values.some((v) => isEmpty(v, strict))) { throw new UnparseableSearchParamError( name, 'mixes empty values with other values', @@ -145,8 +183,8 @@ const parseArrayValues = ( const [value] = values - if (values.some((v) => v.includes(','))) { - if (!allowCommaFormat) { + if (commaHandling !== 'verbatim' && values.some((v) => v.includes(','))) { + if (commaHandling === 'reject') { throw new UnparseableSearchParamError( name, 'uses the bracket array format with a value containing a comma ","', @@ -163,27 +201,28 @@ const parseArrayValues = ( const parts = value.split(',') - if (parts.some(isBlank)) { + if (parts.some((v) => isEmpty(v, strict))) { throw new UnparseableSearchParamError( name, 'uses the comma array format with one or more empty values', ) } - return parts.map((v) => parseLeaf(v, elementType)) + return parts.map((v) => parseLeaf(v, elementType, strict)) } - return values.map((v) => parseLeaf(v, elementType)) + return values.map((v) => parseLeaf(v, elementType, strict)) } const parseRecordParam = ( searchParams: URLSearchParams, name: string, elementType: LeafType, + strict: boolean, ): unknown => { if (searchParams.has(name)) { assertNoNestedParams(searchParams, name) - return parseNestedValue(searchParams, name) + return parseNestedValue(searchParams, name, strict) } const prefix = `${name}.` @@ -216,7 +255,7 @@ const parseRecordParam = ( ) } - return [recordKey, parseLeaf(value, elementType)] + return [recordKey, parseLeaf(value, elementType, strict)] }) return Object.fromEntries(entries) @@ -227,6 +266,7 @@ const parseRecordParam = ( const parseNestedValue = ( searchParams: URLSearchParams, name: string, + strict: boolean, ): unknown => { const values = searchParams.getAll(name) const [value] = values @@ -240,7 +280,7 @@ const parseNestedValue = ( ) } - if (isBlank(value)) return null + if (isEmpty(value, strict)) return null return value } @@ -260,16 +300,31 @@ const assertNoNestedParams = ( } } -const parseLeaf = (value: string, type: LeafType): unknown => { +const parseLeaf = (value: string, type: LeafType, strict: boolean): unknown => { // Zero-length strings are not serializable, so an empty value is null. if (type === 'string') return value.length === 0 ? null : value + if (strict) { + if (value.length === 0) return null + + // The serializer never pads values with whitespace, + // so pass such values through unchanged. + if (value.trim() !== value) return value + + if (type === 'number') return parseNumber(value) + if (type === 'boolean') return parseStrictBoolean(value) + if (type === 'date') return parseDate(value) + + // A null param has no other parseable value, so pass the value through. + return value + } + const trimmed = value.trim() if (trimmed.length === 0) return null if (type === 'number') return parseNumber(trimmed) - if (type === 'boolean') return parseBoolean(trimmed) + if (type === 'boolean') return parseGenerousBoolean(trimmed) if (type === 'date') return parseDate(trimmed) // A null param has no other parseable value, so pass the value through. @@ -287,19 +342,29 @@ const parseNumber = (v: string): number | string => { const truthyValues = ['true', 'True', 'TRUE', 'yes', 'Yes', 'YES', '1'] const falsyValues = ['false', 'False', 'FALSE', 'no', 'No', 'NO', '0'] -const parseBoolean = (v: string): boolean | string => { +const parseGenerousBoolean = (v: string): boolean | string => { if (truthyValues.includes(v)) return true if (falsyValues.includes(v)) return false return v } +// The serializer only outputs the strings true and false. +const parseStrictBoolean = (v: string): boolean | string => { + if (v === 'true') return true + if (v === 'false') return false + return v +} + const parseDate = (v: string): Date | string => { const date = new Date(v) if (isNaN(date.getTime())) return v return date } -const isBlank = (v: string): boolean => v.trim().length === 0 +// The serializer never outputs whitespace-only values, +// so they are only treated as empty when parsing generously. +const isEmpty = (v: string, strict: boolean): boolean => + strict ? v.length === 0 : v.trim().length === 0 export class UnparseableSearchParamError extends Error { constructor(name: string, message: string) { diff --git a/test/bijection.test.ts b/test/bijection.test.ts index 0c490d7..7f48f8d 100644 --- a/test/bijection.test.ts +++ b/test/bijection.test.ts @@ -316,12 +316,25 @@ test( test( 'array values containing a comma', - notInvertible, - { foo: ['a,b'] }, + bijection, + { + foo: ['a,b', 'c,d'], + }, z.object({ foo: z.array(z.string()) }), - { foo: ['a', 'b'] }, ) +// When parsing generously, a single array value containing a comma +// is parsed using the comma array format. +test('does not invert array values containing a comma when strict is false', (t) => { + const schema = z.object({ foo: z.array(z.string()) }) + t.deepEqual( + parseUrlSearchParams(serializeUrlSearchParams({ foo: ['a,b'] }), schema, { + strict: false, + }), + { foo: ['a', 'b'] }, + ) +}) + test( 'the empty object, which is serialized as undefined', notInvertible, diff --git a/test/generous-parsing.test.ts b/test/generous-parsing.test.ts index 3412afc..96a3ed6 100644 --- a/test/generous-parsing.test.ts +++ b/test/generous-parsing.test.ts @@ -6,6 +6,10 @@ import { UnparseableSearchParamError, } from '@seamapi/url-search-params-parser' +// Generous parsing must be enabled with strict: false. +const parse = (query: string, schema: ZodSchema): unknown => + parseUrlSearchParams(query, schema, { strict: false }) + const parseEmptyOrWhitespace = test.macro({ title(providedTitle) { return `parses empty or whitespace ${providedTitle} params as null` @@ -13,14 +17,14 @@ const parseEmptyOrWhitespace = test.macro({ exec(t, type: ZodSchema) { const schema = z.object({ foo: type }) const expected = { foo: null } - t.deepEqual(parseUrlSearchParams('foo=', schema), expected) - t.deepEqual(parseUrlSearchParams('foo= ', schema), expected) - t.deepEqual(parseUrlSearchParams('foo= ', schema), expected) - t.deepEqual(parseUrlSearchParams('foo=%20', schema), expected) - t.deepEqual(parseUrlSearchParams('foo=%20%20%20', schema), expected) - t.deepEqual(parseUrlSearchParams('foo=+', schema), expected) - t.deepEqual(parseUrlSearchParams('foo=+++', schema), expected) - t.deepEqual(parseUrlSearchParams('foo= %20 ++ +%20 ', schema), expected) + t.deepEqual(parse('foo=', schema), expected) + t.deepEqual(parse('foo= ', schema), expected) + t.deepEqual(parse('foo= ', schema), expected) + t.deepEqual(parse('foo=%20', schema), expected) + t.deepEqual(parse('foo=%20%20%20', schema), expected) + t.deepEqual(parse('foo=+', schema), expected) + t.deepEqual(parse('foo=+++', schema), expected) + t.deepEqual(parse('foo= %20 ++ +%20 ', schema), expected) }, }) @@ -36,20 +40,20 @@ const trimBeforeParsing = test.macro({ }, exec(t, type: ZodSchema, value: string, expected: unknown) { const schema = z.object({ foo: type }) - t.deepEqual(parseUrlSearchParams(`foo=${value}`, schema), { foo: expected }) - t.deepEqual(parseUrlSearchParams(`foo= ${value}`, schema), { + t.deepEqual(parse(`foo=${value}`, schema), { foo: expected }) + t.deepEqual(parse(`foo= ${value}`, schema), { foo: expected, }) - t.deepEqual(parseUrlSearchParams(`foo=${value} `, schema), { + t.deepEqual(parse(`foo=${value} `, schema), { foo: expected, }) - t.deepEqual(parseUrlSearchParams(`foo= ${value} `, schema), { + t.deepEqual(parse(`foo= ${value} `, schema), { foo: expected, }) - t.deepEqual(parseUrlSearchParams(`foo=%20${value}%20`, schema), { + t.deepEqual(parse(`foo=%20${value}%20`, schema), { foo: expected, }) - t.deepEqual(parseUrlSearchParams(`foo=+++${value}+++`, schema), { + t.deepEqual(parse(`foo=+++${value}+++`, schema), { foo: expected, }) }, @@ -67,15 +71,15 @@ test( test('does not trim whitespace before parsing string params', (t) => { const schema = z.object({ foo: z.string() }) - t.deepEqual(parseUrlSearchParams('foo=+bar+', schema), { foo: ' bar ' }) - t.deepEqual(parseUrlSearchParams('foo=+', schema), { foo: ' ' }) + t.deepEqual(parse('foo=+bar+', schema), { foo: ' bar ' }) + t.deepEqual(parse('foo=+', schema), { foo: ' ' }) }) test('parses additional strings as true', (t) => { const schema = z.object({ foo: z.boolean() }) for (const value of ['true', 'True', 'TRUE', 'yes', 'Yes', 'YES', '1']) { t.deepEqual( - parseUrlSearchParams(`foo=${value}`, schema), + parse(`foo=${value}`, schema), { foo: true }, `parses ${value} as true`, ) @@ -86,7 +90,7 @@ test('parses additional strings as false', (t) => { const schema = z.object({ foo: z.boolean() }) for (const value of ['false', 'False', 'FALSE', 'no', 'No', 'NO', '0']) { t.deepEqual( - parseUrlSearchParams(`foo=${value}`, schema), + parse(`foo=${value}`, schema), { foo: false }, `parses ${value} as false`, ) @@ -97,104 +101,103 @@ const arraySchema = z.object({ foo: z.array(z.string()) }) const numberArraySchema = z.object({ foo: z.array(z.number()) }) test('parses repeated array params like foo=bar&foo=baz', (t) => { - t.deepEqual(parseUrlSearchParams('foo=bar&foo=baz', arraySchema), { + t.deepEqual(parse('foo=bar&foo=baz', arraySchema), { foo: ['bar', 'baz'], }) - t.deepEqual(parseUrlSearchParams('foo=bar', arraySchema), { foo: ['bar'] }) - t.deepEqual(parseUrlSearchParams('foo=1&foo=2', numberArraySchema), { + t.deepEqual(parse('foo=bar', arraySchema), { foo: ['bar'] }) + t.deepEqual(parse('foo=1&foo=2', numberArraySchema), { foo: [1, 2], }) }) test('parses bracket array params like foo[]=bar&foo[]=baz', (t) => { - t.deepEqual(parseUrlSearchParams('foo[]=bar&foo[]=baz', arraySchema), { + t.deepEqual(parse('foo[]=bar&foo[]=baz', arraySchema), { foo: ['bar', 'baz'], }) - t.deepEqual(parseUrlSearchParams('foo[]=bar', arraySchema), { foo: ['bar'] }) - t.deepEqual(parseUrlSearchParams('foo[]=1&foo[]=2', numberArraySchema), { + t.deepEqual(parse('foo[]=bar', arraySchema), { foo: ['bar'] }) + t.deepEqual(parse('foo[]=1&foo[]=2', numberArraySchema), { foo: [1, 2], }) }) test('parses comma array params like foo=bar,baz', (t) => { - t.deepEqual(parseUrlSearchParams('foo=bar,baz', arraySchema), { + t.deepEqual(parse('foo=bar,baz', arraySchema), { foo: ['bar', 'baz'], }) - t.deepEqual(parseUrlSearchParams('foo=1,2', numberArraySchema), { + t.deepEqual(parse('foo=1,2', numberArraySchema), { foo: [1, 2], }) }) test('parses empty or whitespace array params as empty', (t) => { const expected = { foo: [] } - t.deepEqual(parseUrlSearchParams('foo=', arraySchema), expected) - t.deepEqual(parseUrlSearchParams('foo= ', arraySchema), expected) - t.deepEqual(parseUrlSearchParams('foo=%20', arraySchema), expected) - t.deepEqual(parseUrlSearchParams('foo=+++', arraySchema), expected) - t.deepEqual(parseUrlSearchParams('foo[]=', arraySchema), expected) - t.deepEqual(parseUrlSearchParams('foo[]=+++', arraySchema), expected) + t.deepEqual(parse('foo=', arraySchema), expected) + t.deepEqual(parse('foo= ', arraySchema), expected) + t.deepEqual(parse('foo=%20', arraySchema), expected) + t.deepEqual(parse('foo=+++', arraySchema), expected) + t.deepEqual(parse('foo[]=', arraySchema), expected) + t.deepEqual(parse('foo[]=+++', arraySchema), expected) }) test('cannot parse multiple empty or whitespace array params like foo=&foo=', (t) => { - t.throws(() => parseUrlSearchParams('foo=&foo=', arraySchema), { + t.throws(() => parse('foo=&foo=', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=+&foo=%20', arraySchema), { + t.throws(() => parse('foo=+&foo=%20', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo[]=&foo[]=', arraySchema), { + t.throws(() => parse('foo[]=&foo[]=', arraySchema), { instanceOf: UnparseableSearchParamError, }) }) test('cannot parse mixed empty or whitespace array params like foo=&foo=bar', (t) => { - t.throws(() => parseUrlSearchParams('foo=&foo=bar', arraySchema), { + t.throws(() => parse('foo=&foo=bar', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar&foo=', arraySchema), { + t.throws(() => parse('foo=bar&foo=', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar&foo=+++', arraySchema), { + t.throws(() => parse('foo=bar&foo=+++', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo[]=&foo[]=bar', arraySchema), { + t.throws(() => parse('foo[]=&foo[]=bar', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar,,baz', arraySchema), { + t.throws(() => parse('foo=bar,,baz', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar,', arraySchema), { + t.throws(() => parse('foo=bar,', arraySchema), { instanceOf: UnparseableSearchParamError, }) }) test('cannot parse mixed array params like foo=bar,baz&foo=bar&foo[]=baz', (t) => { - t.throws( - () => parseUrlSearchParams('foo=bar,baz&foo=bar&foo[]=baz', arraySchema), - { instanceOf: UnparseableSearchParamError }, - ) - t.throws(() => parseUrlSearchParams('foo=bar&foo[]=baz', arraySchema), { + t.throws(() => parse('foo=bar,baz&foo=bar&foo[]=baz', arraySchema), { + instanceOf: UnparseableSearchParamError, + }) + t.throws(() => parse('foo=bar&foo[]=baz', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar,baz&foo[]=fizz', arraySchema), { + t.throws(() => parse('foo=bar,baz&foo[]=fizz', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo=bar,baz&foo=fizz', arraySchema), { + t.throws(() => parse('foo=bar,baz&foo=fizz', arraySchema), { instanceOf: UnparseableSearchParamError, }) }) test('cannot parse array values containing a comma like foo=a,b&foo=b,c', (t) => { - t.throws(() => parseUrlSearchParams('foo=a,b&foo=b,c', arraySchema), { + t.throws(() => parse('foo=a,b&foo=b,c', arraySchema), { instanceOf: UnparseableSearchParamError, }) }) test('cannot parse array values containing a comma like foo[]=a,b&foo[]=b,c', (t) => { - t.throws(() => parseUrlSearchParams('foo[]=a,b&foo[]=b,c', arraySchema), { + t.throws(() => parse('foo[]=a,b&foo[]=b,c', arraySchema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo[]=a,b', arraySchema), { + t.throws(() => parse('foo[]=a,b', arraySchema), { instanceOf: UnparseableSearchParamError, }) }) diff --git a/test/strict-parsing.test.ts b/test/strict-parsing.test.ts new file mode 100644 index 0000000..f74d89f --- /dev/null +++ b/test/strict-parsing.test.ts @@ -0,0 +1,113 @@ +import test from 'ava' +import { z } from 'zod' + +import { + parseUrlSearchParams, + UnparseableSearchParamError, +} from '@seamapi/url-search-params-parser' + +// Strict parsing is the default and only parses +// the expected output of the serializer. + +const arraySchema = z.object({ foo: z.array(z.string()) }) + +test('strict: parses repeated array params', (t) => { + t.deepEqual(parseUrlSearchParams('foo=bar&foo=baz', arraySchema), { + foo: ['bar', 'baz'], + }) + t.deepEqual(parseUrlSearchParams('foo=bar', arraySchema), { foo: ['bar'] }) + t.deepEqual(parseUrlSearchParams('foo=', arraySchema), { foo: [] }) +}) + +test('strict: does not split array values containing a comma', (t) => { + t.deepEqual(parseUrlSearchParams('foo=a,b', arraySchema), { foo: ['a,b'] }) + t.deepEqual(parseUrlSearchParams('foo=a,b&foo=c,d', arraySchema), { + foo: ['a,b', 'c,d'], + }) + t.deepEqual(parseUrlSearchParams('foo=a,b&foo=c', arraySchema), { + foo: ['a,b', 'c'], + }) +}) + +test('strict: cannot parse the bracket array format', (t) => { + t.throws(() => parseUrlSearchParams('foo[]=bar', arraySchema), { + instanceOf: UnparseableSearchParamError, + }) + t.throws(() => parseUrlSearchParams('foo[]=bar&foo[]=baz', arraySchema), { + instanceOf: UnparseableSearchParamError, + }) + t.throws(() => parseUrlSearchParams('foo[]=', arraySchema), { + instanceOf: UnparseableSearchParamError, + }) + t.throws(() => parseUrlSearchParams('foo=bar&foo[]=baz', arraySchema), { + instanceOf: UnparseableSearchParamError, + }) +}) + +test('strict: cannot parse arrays mixing empty values with other values', (t) => { + t.throws(() => parseUrlSearchParams('foo=&foo=', arraySchema), { + instanceOf: UnparseableSearchParamError, + }) + t.throws(() => parseUrlSearchParams('foo=&foo=bar', arraySchema), { + instanceOf: UnparseableSearchParamError, + }) +}) + +test('strict: only parses true and false as booleans', (t) => { + const schema = z.object({ foo: z.boolean() }) + t.deepEqual(parseUrlSearchParams('foo=true', schema), { foo: true }) + t.deepEqual(parseUrlSearchParams('foo=false', schema), { foo: false }) + for (const value of ['True', 'TRUE', 'yes', '1', 'False', 'NO', '0']) { + t.deepEqual( + parseUrlSearchParams(`foo=${value}`, schema), + { foo: value }, + `passes ${value} through unchanged`, + ) + } +}) + +test('strict: does not trim whitespace before parsing', (t) => { + t.deepEqual(parseUrlSearchParams('foo=+2+', z.object({ foo: z.number() })), { + foo: ' 2 ', + }) + t.deepEqual( + parseUrlSearchParams('foo=+true', z.object({ foo: z.boolean() })), + { foo: ' true' }, + ) +}) + +test('strict: parses whitespace-only values as whitespace, not null', (t) => { + t.deepEqual(parseUrlSearchParams('foo=+', z.object({ foo: z.number() })), { + foo: ' ', + }) + t.deepEqual(parseUrlSearchParams('foo=%20', z.object({ foo: z.boolean() })), { + foo: ' ', + }) + t.deepEqual(parseUrlSearchParams('foo=+', arraySchema), { foo: [' '] }) +}) + +test('strict: parses empty values as null', (t) => { + t.deepEqual(parseUrlSearchParams('foo=', z.object({ foo: z.number() })), { + foo: null, + }) + t.deepEqual(parseUrlSearchParams('foo=', z.object({ foo: z.string() })), { + foo: null, + }) + t.deepEqual( + parseUrlSearchParams( + 'foo=', + z.object({ foo: z.record(z.string(), z.string()) }), + ), + { foo: null }, + ) +}) + +test('strict: record values keep commas and are not trimmed', (t) => { + t.deepEqual( + parseUrlSearchParams( + 'foo.a=x,y&foo.b=+z', + z.object({ foo: z.record(z.string(), z.string()) }), + ), + { foo: { a: 'x,y', b: ' z' } }, + ) +}) diff --git a/test/zod-v4.test.ts b/test/zod-v4.test.ts index 00facf8..930eb9e 100644 --- a/test/zod-v4.test.ts +++ b/test/zod-v4.test.ts @@ -4,14 +4,18 @@ import { z } from 'zod-v4' import { parseUrlSearchParams, + type ParseUrlSearchParamsOptions, UnparseableSchemaError, UnparseableSearchParamError, } from '@seamapi/url-search-params-parser' // The parser inspects Zod internals, which changed between Zod v3 and v4. // These tests mirror the core parsing behavior using schemas built with Zod v4. -const parse = (query: string, schema: unknown): unknown => - parseUrlSearchParams(query, schema as ZodSchema) +const parse = ( + query: string, + schema: unknown, + options?: ParseUrlSearchParamsOptions, +): unknown => parseUrlSearchParams(query, schema as ZodSchema, options) test('zod-v4: parses primitive types', (t) => { const schema = z.object({ @@ -49,12 +53,15 @@ test('zod-v4: parses optional, nullable, default, and readonly wrappers', (t) => }) }) -test('zod-v4: parses arrays in all three formats', (t) => { +test('zod-v4: parses arrays in all three formats when strict is false', (t) => { const schema = z.object({ foo: z.array(z.string()) }) t.deepEqual(parse('foo=a&foo=b', schema), { foo: ['a', 'b'] }) - t.deepEqual(parse('foo[]=a&foo[]=b', schema), { foo: ['a', 'b'] }) - t.deepEqual(parse('foo=a,b', schema), { foo: ['a', 'b'] }) t.deepEqual(parse('foo=', schema), { foo: [] }) + t.deepEqual(parse('foo[]=a&foo[]=b', schema, { strict: false }), { + foo: ['a', 'b'], + }) + t.deepEqual(parse('foo=a,b', schema, { strict: false }), { foo: ['a', 'b'] }) + t.deepEqual(parse('foo=a,b', schema), { foo: ['a,b'] }) }) test('zod-v4: parses number arrays', (t) => { @@ -112,7 +119,7 @@ test('zod-v4: parses enums of numeric TypeScript enums as numbers', (t) => { test('zod-v4: parses enum arrays', (t) => { const schema = z.object({ foo: z.array(z.enum(['a', 'b'])) }) - t.deepEqual(parse('foo=a,b', schema), { foo: ['a', 'b'] }) + t.deepEqual(parse('foo=a,b', schema, { strict: false }), { foo: ['a', 'b'] }) }) test('zod-v4: parses literals', (t) => { @@ -155,10 +162,13 @@ test('zod-v4: parses null and never properties', (t) => { test('zod-v4: throws UnparseableSearchParamError on ambiguous input', (t) => { const schema = z.object({ foo: z.array(z.string()) }) - t.throws(() => parse('foo=a&foo[]=b', schema), { + t.throws(() => parse('foo=a&foo[]=b', schema, { strict: false }), { + instanceOf: UnparseableSearchParamError, + }) + t.throws(() => parse('foo[]=a,b', schema, { strict: false }), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parse('foo[]=a,b', schema), { + t.throws(() => parse('foo[]=a', schema), { instanceOf: UnparseableSearchParamError, }) }) From 891b93ff6e4acf93c8d8f60fca59c8107ea7b8fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:37:04 +0000 Subject: [PATCH 2/2] fix: Align strict mode bracket handling with the serializer In strict mode there is no bracket array format: since the serializer never outputs foo[]= for an array named foo, a param named foo[] is unrelated to foo and is parsed as a param literally named "foo[]". The same applies to record keys ending in [], which are literal keys in strict mode. Previously strict mode threw UnparseableSearchParamError for the bracket format. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015jWzL9LC7Q6bTGaXDoKq4z --- README.md | 7 ++++--- src/lib/parse.ts | 27 +++++++++++++++---------- test/bijection.test.ts | 10 ++++++++++ test/edge-cases.test.ts | 4 +++- test/strict-parsing.test.ts | 40 +++++++++++++++++++++++++++---------- test/zod-v4.test.ts | 3 --- 6 files changed, 63 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ef3616d..c390c87 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,10 @@ making the parser a true inverse of the serializer: - Parses `z.array()` only in the repeated format `foo=1&foo=2`. - Array values may contain a `,` and are never split, e.g., `foo=a,b&foo=c` is parsed as `['a,b', 'c']`. - - The bracket format `foo[]=1` throws an `UnparseableSearchParamError`, - since the serializer never outputs it. + - There is no bracket array format: + since the serializer never outputs it, + a param named `foo[]` is unrelated to the param `foo` + and is parsed as a param literally named `foo[]`. - For `z.boolean()`, only the strings `true` and `false` are parsed. - Whitespace is significant and is never trimmed: only a completely empty value is parsed as `null` @@ -77,7 +79,6 @@ These throw an `UnparseableSearchParamError`: since this would be a null object containing a value. - A param nested inside a record param, e.g., `foo.a.b=1` for `z.record(z.string(), z.number())`. -- In strict mode, an array param using the bracket format, e.g., `foo[]=1`. - In generous mode (`strict: false`): - An array param that mixes array formats, e.g., `foo=1&foo[]=2` or `foo=1,2&foo=3`. diff --git a/src/lib/parse.ts b/src/lib/parse.ts index 16dda5d..e0e13a8 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -131,18 +131,21 @@ const parseArrayParam = ( strict: boolean, ): unknown => { const repeatedValues = searchParams.getAll(name) - const bracketName = `${name}[]` - const bracketValues = searchParams.getAll(bracketName) - if (strict && bracketValues.length > 0) { - throw new UnparseableSearchParamError( - bracketName, - 'uses the bracket array format, ' + - 'which is never output by the serializer ' + - 'and only parsed when strict is false', - ) + // The serializer only outputs the repeated array format, + // so in strict mode a param named foo[] is unrelated to the param foo: + // it is a param literally named "foo[]". + if (strict) { + if (repeatedValues.length === 0) return undefined + return parseArrayValues(name, repeatedValues, elementType, { + commaHandling: 'verbatim', + strict, + }) } + const bracketName = `${name}[]` + const bracketValues = searchParams.getAll(bracketName) + if (repeatedValues.length > 0 && bracketValues.length > 0) { throw new UnparseableSearchParamError( name, @@ -160,7 +163,7 @@ const parseArrayParam = ( if (repeatedValues.length === 0) return undefined return parseArrayValues(name, repeatedValues, elementType, { - commaHandling: strict ? 'verbatim' : 'split', + commaHandling: 'split', strict, }) } @@ -235,7 +238,9 @@ const parseRecordParam = ( const entries = keys.map<[string, unknown]>((k) => { const recordKey = k.slice(prefix.length) - if (recordKey.includes('.') || recordKey.endsWith('[]')) { + // In strict mode there is no bracket array format, + // so a record key ending in [] is a literal record key. + if (recordKey.includes('.') || (!strict && recordKey.endsWith('[]'))) { throw new UnparseableSearchParamError( k, 'is nested inside a record parameter, ' + diff --git a/test/bijection.test.ts b/test/bijection.test.ts index 7f48f8d..ba33e5d 100644 --- a/test/bijection.test.ts +++ b/test/bijection.test.ts @@ -323,6 +323,16 @@ test( z.object({ foo: z.array(z.string()) }), ) +test( + 'params literally named with a bracket suffix', + bijection, + { + 'foo[]': 'a', + bar: ['b', 'c'], + }, + z.object({ 'foo[]': z.string(), bar: z.array(z.string()) }), +) + // When parsing generously, a single array value containing a comma // is parsed using the comma array format. test('does not invert array values containing a comma when strict is false', (t) => { diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index fc2576b..9397e35 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -131,7 +131,9 @@ test('cannot parse nested params inside a record', (t) => { t.throws(() => parseUrlSearchParams('foo.a.b=1', schema), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parseUrlSearchParams('foo.a[]=1', schema), { + // In strict mode, a[] is a literal record key, + // but in generous mode it is the unsupported bracket array format. + t.throws(() => parseUrlSearchParams('foo.a[]=1', schema, { strict: false }), { instanceOf: UnparseableSearchParamError, }) }) diff --git a/test/strict-parsing.test.ts b/test/strict-parsing.test.ts index f74d89f..c2c1623 100644 --- a/test/strict-parsing.test.ts +++ b/test/strict-parsing.test.ts @@ -29,21 +29,41 @@ test('strict: does not split array values containing a comma', (t) => { }) }) -test('strict: cannot parse the bracket array format', (t) => { - t.throws(() => parseUrlSearchParams('foo[]=bar', arraySchema), { - instanceOf: UnparseableSearchParamError, +test('strict: there is no bracket array format', (t) => { + // A param named foo[] is unrelated to the array param foo, + // exactly as the serializer treats them. + t.deepEqual(parseUrlSearchParams('foo[]=bar', arraySchema), { + foo: undefined, }) - t.throws(() => parseUrlSearchParams('foo[]=bar&foo[]=baz', arraySchema), { - instanceOf: UnparseableSearchParamError, + t.deepEqual(parseUrlSearchParams('foo[]=bar&foo[]=baz', arraySchema), { + foo: undefined, }) - t.throws(() => parseUrlSearchParams('foo[]=', arraySchema), { - instanceOf: UnparseableSearchParamError, - }) - t.throws(() => parseUrlSearchParams('foo=bar&foo[]=baz', arraySchema), { - instanceOf: UnparseableSearchParamError, + t.deepEqual(parseUrlSearchParams('foo=bar&foo[]=baz', arraySchema), { + foo: ['bar'], }) }) +test('strict: parses params literally named with a bracket suffix', (t) => { + t.deepEqual( + parseUrlSearchParams('foo[]=bar', z.object({ 'foo[]': z.string() })), + { 'foo[]': 'bar' }, + ) + t.deepEqual( + parseUrlSearchParams( + 'foo[]=bar&foo[]=baz', + z.object({ 'foo[]': z.array(z.string()) }), + ), + { 'foo[]': ['bar', 'baz'] }, + ) + t.deepEqual( + parseUrlSearchParams( + 'foo.a[]=1', + z.object({ foo: z.record(z.string(), z.number()) }), + ), + { foo: { 'a[]': 1 } }, + ) +}) + test('strict: cannot parse arrays mixing empty values with other values', (t) => { t.throws(() => parseUrlSearchParams('foo=&foo=', arraySchema), { instanceOf: UnparseableSearchParamError, diff --git a/test/zod-v4.test.ts b/test/zod-v4.test.ts index 930eb9e..b5fbe98 100644 --- a/test/zod-v4.test.ts +++ b/test/zod-v4.test.ts @@ -168,9 +168,6 @@ test('zod-v4: throws UnparseableSearchParamError on ambiguous input', (t) => { t.throws(() => parse('foo[]=a,b', schema, { strict: false }), { instanceOf: UnparseableSearchParamError, }) - t.throws(() => parse('foo[]=a', schema), { - instanceOf: UnparseableSearchParamError, - }) }) test('zod-v4: throws UnparseableSearchParamError on repeated non-array params', (t) => {