Last active
April 19, 2025 19:41
-
-
Save JacobWeisenburger/9256eae415f6b0a04b718d633266a4e0 to your computer and use it in GitHub Desktop.
a way to parse URLSearchParams with Zod
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { z } from 'zod' | |
function safeParseJSON ( string: string ): any { | |
try { return JSON.parse( string ) } | |
catch { return string } | |
} | |
function searchParamsToValues ( searchParams: URLSearchParams ): Record<string, any> { | |
return Array.from( searchParams.keys() ).reduce( ( record, key ) => { | |
const values = searchParams.getAll( key ).map( safeParseJSON ) | |
return { ...record, [ key ]: values.length > 1 ? values : values[ 0 ] } | |
}, {} as Record<string, any> ) | |
} | |
function makeSearchParamsObjSchema< | |
Schema extends z.ZodObject<z.ZodRawShape> | |
> ( schema: Schema ) { | |
return z.instanceof( URLSearchParams ) | |
.transform( searchParamsToValues ) | |
.pipe( schema ) | |
} | |
function coerceToArray< | |
Schema extends z.ZodArray<z.ZodTypeAny> | |
> ( schema: Schema ) { | |
return z.union( [ | |
z.any().array(), | |
z.any().transform( x => [ x ] ), | |
] ).pipe( schema ) | |
} | |
// `objSchema` isn't coupled to URLSearchParams, | |
// so it can easily be used elsewhere or it can come from elsewhere | |
const objSchema = z.object( { | |
manyStrings: coerceToArray( z.string().array().min( 1 ) ), | |
manyNumbers: coerceToArray( z.number().array().min( 1 ) ), | |
oneStringInArray: coerceToArray( z.string().array().min( 1 ).max( 1 ) ), | |
string: z.string().min( 1 ), | |
posNumber: z.number().positive(), | |
range: z.number().min( 0 ).max( 5 ), | |
boolean: z.boolean(), | |
true: z.literal( true ), | |
false: z.literal( false ), | |
null: z.null(), | |
plainDate: z.string().refine( | |
value => /\d{4}-\d{2}-\d{2}/.test( value ), | |
value => ( { message: `Invalid plain date: ${ value }` } ), | |
), | |
tuple: z.tuple( [ z.string(), z.number() ] ), | |
object: z.object( { | |
foo: z.string(), | |
bar: z.number(), | |
} ), | |
} ) | |
const searchParamsObjSchema = makeSearchParamsObjSchema( objSchema ) | |
// happy path | |
{ | |
const searchParams = new URLSearchParams( { | |
manyStrings: 'hello', | |
manyNumbers: '123', | |
oneStringInArray: 'Leeeeeeeeeroyyyyyyy Jenkiiiiiins!', | |
boolean: 'true', | |
true: 'true', | |
false: 'false', | |
string: 'foo', | |
posNumber: '42.42', | |
} ) | |
searchParams.append( 'manyStrings', 'world' ) | |
searchParams.append( 'manyNumbers', '456' ) | |
searchParams.append( 'range', '4' ) | |
searchParams.append( 'null', 'null' ) | |
searchParams.append( 'plainDate', '2021-01-01' ) | |
searchParams.append( 'tuple', '["foo",42]' ) | |
searchParams.append( 'object', '{"foo":"foo","bar":42}' ) | |
const result = searchParamsObjSchema.safeParse( searchParams ) | |
console.log( | |
result.success | |
? result.data | |
: result.error.format() | |
) | |
// { | |
// manyStrings: [ "hello", "world" ], | |
// manyNumbers: [ 123, 456 ], | |
// oneStringInArray: [ "Leeeeeeeeeroyyyyyyy Jenkiiiiiins!" ], | |
// string: "foo", | |
// posNumber: 42.42, | |
// range: 4, | |
// boolean: true, | |
// true: true, | |
// false: false, | |
// null: null, | |
// plainDate: "2021-01-01", | |
// tuple: [ "foo", 42 ], | |
// object: { foo: "foo", bar: 42 } | |
// } | |
} | |
// sad path | |
{ | |
const searchParams = new URLSearchParams( { | |
manyStrings: 'hello', | |
manyNumbers: '123', | |
oneStringInArray: 'Leeeeeeeeeroyyyyyyy', | |
string: '', | |
boolean: 'foo', | |
true: 'foo', | |
false: 'foo', | |
posNumber: '-42', | |
} ) | |
searchParams.append( 'oneStringInArray', 'Jenkiiiiiins!' ) | |
searchParams.append( 'range', '6' ) | |
searchParams.append( 'null', 'undefined' ) | |
searchParams.append( 'plainDate', '2021-0-01' ) | |
searchParams.append( 'tuple', '[42,"foo"]' ) | |
searchParams.append( 'object', '{"foo":42,"bar":"foo"}' ) | |
const result = searchParamsObjSchema.safeParse( searchParams ) | |
console.log( | |
result.success | |
? result.data | |
: result.error.format() | |
) | |
// { | |
// _errors: [], | |
// oneStringInArray: { _errors: [ "Array must contain at most 1 element(s)" ] }, | |
// string: { _errors: [ "String must contain at least 1 character(s)" ] }, | |
// posNumber: { _errors: [ "Number must be greater than 0" ] }, | |
// range: { _errors: [ "Number must be less than or equal to 5" ] }, | |
// boolean: { _errors: [ "Expected boolean, received string" ] }, | |
// true: { _errors: [ "Invalid literal value, expected true" ] }, | |
// false: { _errors: [ "Invalid literal value, expected false" ] }, | |
// null: { _errors: [ "Expected null, received string" ] }, | |
// plainDate: { _errors: [ "Invalid plain date: 2021-0-01" ] }, | |
// tuple: { | |
// "0": { _errors: [ "Expected string, received number" ] }, | |
// "1": { _errors: [ "Expected number, received string" ] }, | |
// _errors: [] | |
// }, | |
// object: { | |
// _errors: [], | |
// foo: { _errors: [ "Expected string, received number" ] }, | |
// bar: { _errors: [ "Expected number, received string" ] } | |
// } | |
// } | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thank you for sharing it