ts-search-params
Version:
Type Safe Search Params
123 lines (111 loc) • 3.4 kB
text/typescript
type Options<T extends Record<string, unknown>, Q extends boolean> = {
validate?: (searchParamsObject: Record<string, unknown>) => T
questionMark?: Q
}
export class TSSearchParams<
T extends Record<string, unknown>,
Q extends boolean = false,
> {
#value: T
#options: Required<Options<T, Q>>
constructor(
init?: string | URLSearchParams | undefined,
options: Options<T, Q> = {},
) {
const searchParams =
!init || typeof init === 'string' ? new URLSearchParams(init) : init
const value = {} as T
for (let [_k, val] of searchParams.entries()) {
value[_k as keyof T] = serialize(val) as any
}
this.#value = value
this.#options = {
validate: options.validate || (() => this.#value),
questionMark: options.questionMark || (false as Q),
}
}
#validated() {
return this.#options.validate(this.#value)
}
assign(partial: Partial<T>) {
Object.assign(this.#value, partial)
return this
}
toString(): Q extends true ? `?${string}` : string {
const qs = Object.entries(this.#validated()).reduce((qs, [_k, _v]) => {
// remove undefined at the top level
if (_v === undefined) return qs
const prefix = qs.length ? '&' : ''
const key = encodeURIComponent(_k)
const value = deserialize(_v)
return qs + `${prefix}${key}=${value}`
}, '')
return (this.#options.questionMark ? `?${qs}` : qs) as Q extends true
? `?${string}`
: string
}
toObject() {
return this.#validated()
}
get<K extends keyof T>(key: K): T[K] {
return this.#validated()[key]
}
}
function serialize(val: string): any {
if (isSurroundedBy(val, '{', '}') || isSurroundedBy(val, '[', ']')) {
return JSON.parse(val, (_, val) => {
return typeof val === 'string' ? serialize(val) : val
})
}
if (isSurroundedBy(val, '"')) {
return val.slice(1, -1)
}
if (val === 'true' || val === 'false') {
return val === 'true'
}
if (val === 'null') {
return null
}
if (val === 'NaN') {
return NaN
}
if (val === 'undefined') {
return undefined
}
if (!isNaN(+val) && (+val).toString() === val) {
return +val
}
return val
}
function deserialize(val: any) {
if (isObjectOrArray(val)) {
let scope: any
return encodeURIComponent(
JSON.stringify(val, (_, value) => {
if (isObjectOrArray(value)) {
scope = value
return value
}
if (typeof value === 'string') {
return `"${value}"`
// remove undefined from objects
} else if (typeof value === 'undefined' && !Array.isArray(scope)) {
return undefined
} else {
return String(value)
}
}),
)
}
// top level empty string values result in `value=` as they go through this case
if (val && typeof val === 'string') {
return encodeURIComponent(`"${val}"`)
}
return String(val)
}
function isSurroundedBy(str: string, start: string, end: string = start) {
return str.startsWith(start) && str.endsWith(end)
}
function isObjectOrArray(val: any): val is Record<string, unknown> | unknown[] {
return typeof val === 'object' && val !== null
}