import type { z } from "zod" import type { NextSearchParams } from "@/types/params" type ParseOptions = { keyRenameMap?: Record typeHints?: Record schema?: z.ZodObject } type ParseOptionsWithSchema = ParseOptions & { schema: z.ZodObject } // This ensures that the return type is correct when a schema is provided export function parseSearchParams( searchParams: NextSearchParams, options: ParseOptionsWithSchema ): z.infer export function parseSearchParams( searchParams: NextSearchParams, options?: ParseOptions ): Record /** * Parses URL search parameters into a structured object. * This function can handle nested objects, arrays, and type validation/transformation using Zod schema. * * @param searchParams - The object to parse * @param options.keyRenameMap - Optional mapping of keys to rename, ie { "oldKey": "newKey" } * @param options.typeHints - Optional type hints to force certain keys to be treated as arrays * @param options.schema - Pass a Zod schema to validate and transform the parsed search parameters and get a typed return value * * Supported formats: * - Objects: `user.name=John&user.age=30` * - Arrays: `tags[0]=javascript&tags[1]=typescript` * - Arrays of objects: `tags[0].name=javascript&tags[0].age=30` * - Nested arrays: `tags[0].languages[0]=javascript&tags[0].languages[1]=typescript` * - Comma-separated arrays: `tags=javascript,typescript` * * For comma-separated arrays you must use the `typeHints` * option to inform the parser that the key should be treated as an array. */ export function parseSearchParams( searchParams: NextSearchParams, options?: ParseOptions ) { const entries = Object.entries(searchParams) const buildObject = getBuilder(options || {}) const resultObject: Record = {} for (const [key, value] of entries) { const paths = key.split(".") if (Array.isArray(value)) { throw new Error( `Arrays from duplicate keys (?a=1&a=2) are not yet supported.` ) } if (!value) { continue } buildObject(resultObject, paths, value) } if (options?.schema) { return options.schema.parse(resultObject) } return resultObject } // Use a higher-order function to avoid passing the options // object every time we recursively call the builder function getBuilder(options: ParseOptions) { const keyRenameMap = options.keyRenameMap || {} const typeHints = options.typeHints || {} return function buildNestedObject( obj: Record, paths: string[], value: string ) { if (paths.length === 0) return const path = paths[0] const remainingPaths = paths.slice(1) // Extract the key name and optional array index const match = path.match(/^([^\[]+)(?:\[(\d+)\])?$/) if (!match) return const key = keyRenameMap[match[1]] || match[1] const index = match[2] ? parseInt(match[2]) : null const forceCommaSeparatedArray = typeHints[key] === "COMMA_SEPARATED_ARRAY" const hasIndex = index !== null // If we've reached the last path, set the value if (remainingPaths.length === 0) { // This is either an array or a value that is // forced to be an array by the typeHints if (hasIndex || forceCommaSeparatedArray) { if (isNotArray(obj[key])) obj[key] = [] if (!hasIndex || forceCommaSeparatedArray) { obj[key] = value.split(",") return } obj[key][index] = value return } obj[key] = value return } if (hasIndex) { // If the key is an array, ensure array and element at index exists if (isNotArray(obj[key])) obj[key] = [] if (!obj[key][index]) obj[key][index] = {} buildNestedObject(obj[key][index], remainingPaths, value) return } // Otherwise, it should be an object if (!obj[key]) obj[key] = {} buildNestedObject(obj[key], remainingPaths, value) } } function isNotArray(value: any) { return !value || typeof value !== "object" || !Array.isArray(value) } type SerializeOptions = { keyRenameMap?: Record typeHints?: Record initialSearchParams?: URLSearchParams } /** * Serializes an object into URL search parameters. * * @param obj - The object to serialize * @param options.keyRenameMap - Optional mapping of keys to rename, ie { "oldKey": "newKey" } * @param options.typeHints - Optional type hints to force certain keys to be treated as comma separated arrays * @param options.initialSearchParams - Optional initial URL search parameters to merge with the serialized object * @returns URLSearchParams - The serialized URL search parameters * * To force a key to be removed when merging with initialSearchParams, set its value to `null` in the object. * Arrays are not merged, they will always replace existing values. */ export function serializeSearchParams( obj: Record, options?: SerializeOptions ): URLSearchParams { const params = new URLSearchParams(options?.initialSearchParams) const keyRenameMap = options?.keyRenameMap || {} const typeHints = options?.typeHints || {} function buildParams(obj: unknown, prefix: string) { if (obj === null || obj === undefined) return if (!isRecord(obj)) { params.set(prefix, String(obj)) return } for (const key in obj) { const value = obj[key] const renamedKey = keyRenameMap[key] || key const paramKey = prefix ? `${prefix}.${renamedKey}` : renamedKey if (value === null) { params.delete(paramKey) continue } if (Array.isArray(value)) { if (typeHints[key] === "COMMA_SEPARATED_ARRAY") { params.set(paramKey, value.join(",")) continue } // If an array value already exists (from initialSearchParams), // we need to first remove it since it can't be merged. deleteAllKeysStartingWith(params, renamedKey) value.forEach((item, index) => { const indexedKey = `${renamedKey}[${index}]` const arrayKey = prefix ? `${prefix}.${indexedKey}` : indexedKey buildParams(item, arrayKey) }) continue } if (typeof value === "object" && value !== null) { buildParams(value, paramKey) continue } params.set(paramKey, String(value)) } } buildParams(obj, "") return params } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } function deleteAllKeysStartingWith(searchParams: URLSearchParams, key: string) { const keysToDelete = Array.from(searchParams.keys()).filter( (k) => k.startsWith(key) || k === key ) for (const k of keysToDelete) { searchParams.delete(k) } }