URLSearchParams

createURLSearchParamsCodec turns Search Fields into a directly usable URLSearchParams codec.

import { createURLSearchParamsCodec } from '@guanriyue/decurl/codec';

const codec = createURLSearchParamsCodec(searchFields);

It provides two methods:

codec.decode(searchParams);
codec.encode(values, options);

In most cases, developers do not need to call decode manually. Daily business code usually obtains values through higher-level state reading APIs.

But encode is still important. When you need to build links, navigation targets, shareable URLs, test fixtures, or search strings outside React Router, encode reuses the same Search Fields and avoids hand-written query strings.

Encode

The input of encode comes from Search Fields. Both field names and field values are checked by TypeScript.

import { 
const createURLSearchParamsCodec: <TDefinition extends RecordCodec>(definition: TDefinition) => URLSearchParamsCodec<TDefinition>
createURLSearchParamsCodec
,
const defineFields: <TDefinition extends RecordCodec>(definition: TDefinition) => DefinedFields<TDefinition>
defineFields
,
function field<TValue>(definition: SingleOptionalFieldCodec<TValue> & {
    name: DefinedFieldName;
}): WithDefinedFieldName<SingleOptionalFieldCodec<TValue>> (+7 overloads)
field
} from '@guanriyue/decurl/codec';
import {
function elementOf<const TValues extends readonly unknown[]>(values: TValues): Decode<unknown, TValues[number]> (+1 overload)
elementOf
,
const min: (minimum: number) => Decode<number, number>
min
,
function pipe<A, B>(ab: Decode<A, B>): Decode<A, B> (+9 overloads)
pipe
,
const shape: Shape
shape
,
const toNumber: Decode<unknown, number>
toNumber
,
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
} from '@guanriyue/decurl/decode';
const
const searchFields: {
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}
searchFields
=
defineFields<{
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: SingleRequiredFieldCodec<number>;
    sort: SingleRequiredFieldCodec<"relevance" | "latest">;
}>(definition: {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: SingleRequiredFieldCodec<number>;
    sort: SingleRequiredFieldCodec<"relevance" | "latest">;
}): {
    ...;
}
defineFields
({
keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>
keyword
:
field<string>(definition: FieldCodecBase<string> & {
    mode?: "single";
    decode: Decode<string, string>;
    encode?: ((value: string) => string | null | undefined) | undefined;
    defaultValue?: never;
} & {
    name: DefinedFieldName;
}): WithDefinedFieldName<SingleOptionalFieldCodec<string>> (+7 overloads)
field
({
name: (FieldName | undefined) & DefinedFieldName
name
: 'q',
decode: Decode<string, string>
decode
:
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
,
}),
page: SingleRequiredFieldCodec<number>
page
:
field<number>(definition: SingleRequiredFieldCodec<number>): SingleRequiredFieldCodec<number> (+7 overloads)
field
({
decode: Decode<string, number>
decode
:
pipe<string, string, string, number, number>(ab: Decode<string, string>, bc: Decode<string, string>, cd: Decode<string, number>, de: Decode<number, number>): Decode<string, number> (+9 overloads)
pipe
(
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
,
const shape: Shape
shape
.
integer: Decode<string, string>
integer
,
const toNumber: Decode<unknown, number>
toNumber
,
function min(minimum: number): Decode<number, number>
min
(1)),
defaultValue: number
defaultValue
: 1,
}),
sort: SingleRequiredFieldCodec<"relevance" | "latest">
sort
:
field<"relevance" | "latest">(definition: SingleRequiredFieldCodec<"relevance" | "latest">): SingleRequiredFieldCodec<"relevance" | "latest"> (+7 overloads)
field
({
decode: Decode<string, "relevance" | "latest">
decode
:
elementOf<readonly ["relevance", "latest"]>(values: readonly ["relevance", "latest"]): Decode<unknown, "relevance" | "latest"> (+1 overload)
elementOf
(['relevance', 'latest']),
defaultValue: NonNullable<"relevance" | "latest">
defaultValue
: 'relevance',
}), }); const
const codec: URLSearchParamsCodec<{
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}>
codec
=
createURLSearchParamsCodec<{
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}>(definition: {
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}): URLSearchParamsCodec<...>
createURLSearchParamsCodec
(
const searchFields: {
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}
searchFields
);
const
const search: URLSearchParams
search
=
const codec: URLSearchParamsCodec<{
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}>
codec
.
encode: (values: Partial<{
    keyword: string | null | undefined;
    page: number | null | undefined;
    sort: NonNullable<"relevance" | "latest" | null | undefined> | null | undefined;
}>, options?: EncodeFieldsOptions) => URLSearchParams
encode
({
keyword?: string | null | undefined
keyword
: 'router',
page?: number | null | undefined
page
: 2,
sort?: NonNullable<"relevance" | "latest" | null | undefined> | null | undefined
sort
: 'latest',
});
const search: URLSearchParams
search
.
URLSearchParams.toString(): string
toString
();
// q=router&page=2&sort=latest

This is more maintainable than hand-written query strings:

`?q=${keyword}&page=${page}&sort=${sort}`;

Hand-written strings bypass Search Fields: whether the key is correct, whether the value needs encoding, and whether the default value should be omitted all become things the caller must remember.

Type errors surface at the call site:

import { 
const createURLSearchParamsCodec: <TDefinition extends RecordCodec>(definition: TDefinition) => URLSearchParamsCodec<TDefinition>
createURLSearchParamsCodec
,
const defineFields: <TDefinition extends RecordCodec>(definition: TDefinition) => DefinedFields<TDefinition>
defineFields
,
function field<TValue>(definition: SingleOptionalFieldCodec<TValue> & {
    name: DefinedFieldName;
}): WithDefinedFieldName<SingleOptionalFieldCodec<TValue>> (+7 overloads)
field
} from '@guanriyue/decurl/codec';
import {
function elementOf<const TValues extends readonly unknown[]>(values: TValues): Decode<unknown, TValues[number]> (+1 overload)
elementOf
,
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
} from '@guanriyue/decurl/decode';
const
const searchFields: {
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}
searchFields
=
defineFields<{
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    sort: SingleRequiredFieldCodec<"relevance" | "latest">;
}>(definition: {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    sort: SingleRequiredFieldCodec<"relevance" | "latest">;
}): {
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    sort: WithDefinedFieldName<...>;
}
defineFields
({
keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>
keyword
:
field<string>(definition: FieldCodecBase<string> & {
    mode?: "single";
    decode: Decode<string, string>;
    encode?: ((value: string) => string | null | undefined) | undefined;
    defaultValue?: never;
} & {
    name: DefinedFieldName;
}): WithDefinedFieldName<SingleOptionalFieldCodec<string>> (+7 overloads)
field
({
name: (FieldName | undefined) & DefinedFieldName
name
: 'q',
decode: Decode<string, string>
decode
:
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
,
}),
sort: SingleRequiredFieldCodec<"relevance" | "latest">
sort
:
field<"relevance" | "latest">(definition: SingleRequiredFieldCodec<"relevance" | "latest">): SingleRequiredFieldCodec<"relevance" | "latest"> (+7 overloads)
field
({
decode: Decode<string, "relevance" | "latest">
decode
:
elementOf<readonly ["relevance", "latest"]>(values: readonly ["relevance", "latest"]): Decode<unknown, "relevance" | "latest"> (+1 overload)
elementOf
(['relevance', 'latest']),
defaultValue: NonNullable<"relevance" | "latest">
defaultValue
: 'relevance',
}), }); const
const codec: URLSearchParamsCodec<{
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}>
codec
=
createURLSearchParamsCodec<{
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}>(definition: {
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}): URLSearchParamsCodec<...>
createURLSearchParamsCodec
(
const searchFields: {
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}
searchFields
);
const codec: URLSearchParamsCodec<{
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}>
codec
.
encode: (values: Partial<{
    keyword: string | null | undefined;
    sort: NonNullable<"relevance" | "latest" | null | undefined> | null | undefined;
}>, options?: EncodeFieldsOptions) => URLSearchParams
encode
({
keyword?: string | null | undefined
keyword
: 'router',
sort: 'oldest',
Type '"oldest"' is not assignable to type 'NonNullable<"relevance" | "latest" | null | undefined> | null | undefined'.
});
const codec: URLSearchParamsCodec<{
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}>
codec
.
encode: (values: Partial<{
    keyword: string | null | undefined;
    sort: NonNullable<"relevance" | "latest" | null | undefined> | null | undefined;
}>, options?: EncodeFieldsOptions) => URLSearchParams
encode
({
typo: 'router',
Object literal may only specify known properties, and 'typo' does not exist in type 'Partial<{ keyword: string | null | undefined; sort: NonNullable<"relevance" | "latest" | null | undefined> | null | undefined; }>'.
});

Patch Encode

encode uses patch semantics by default.

const nextSearch = codec.encode(
  { page: 3 },
  { base: '?q=router&page=2&sort=latest' },
);

nextSearch.toString();
// q=router&sort=latest&page=3

Patch encode rules:

  • Only Search Fields fields included in the patch are handled.
  • Fields not present in the patch are not modified.
  • Keys in the patch that are not part of Search Fields are ignored.
  • null or undefined deletes the corresponding URL key.
codec.encode(
  { keyword: undefined },
  { base: '?q=router&page=2' },
).toString();
// page=2

This is not a full update. URL search params are often shared by multiple components or features, so patch encode fits local updates better.

base represents the current URLSearchParams. When base is provided, encode starts from a copy of it and then applies the patch.

codec.encode(
  { page: 2 },
  { base: new URLSearchParams('?q=router&debug=true&utm_source=docs') },
).toString();
// q=router&debug=true&utm_source=docs&page=2

This matters on real pages because the URL may contain params that the current component does not own. Search Fields only update fields they know about, and other params are preserved.

Write-back Rules

Canonical Key

When a field has multiple name values, the first one is the canonical key and the rest are legacy aliases.

const searchFields = defineFields({
  page: field({
    name: ['page', 'p'],
    decode: pipe(shape.integer, toNumber, min(1)),
    defaultValue: 1,
  }),
});

Write-back always uses the canonical key.

const codec = createURLSearchParamsCodec(searchFields);

codec.encode(
  { page: 3 },
  { base: '?p=2' },
).toString();
// page=3

As long as the patch touches the field, encode cleans up legacy aliases so old and new keys do not remain in the URL together.

Old link: ?p=2
New write-back: ?page=3

Default Value

When writing a value equal to defaultValue, Decurl deletes the corresponding URL key by default, keeping the URL concise.

const searchFields = defineFields({
  page: field({
    decode: pipe(shape.integer, toNumber, min(1)),
    defaultValue: 1,
  }),
});

const codec = createURLSearchParamsCodec(searchFields);

codec.encode({ page: 1 }).toString();
// ''

If you want default values to appear in the URL, pass preserveDefault: true.

codec.encode({ page: 1 }, { preserveDefault: true }).toString();
// page=1

preserveDefault is an encode option, not a FieldCodec property.

Multi Field

Multi fields write multiple keys with the same name in array order.

import { mapItems, pipe, unique } from '@guanriyue/decurl/decode';

const searchFields = defineFields({
  tag: field({
    mode: 'multi',
    decode: pipe(mapItems(trim), unique),
  }),
});

const codec = createURLSearchParamsCodec(searchFields);

codec.encode({ tag: ['react', 'router'] }).toString();
// tag=react&tag=router

If a multi field has aliases, the write-back rule is the same: use the canonical key and clean up legacy aliases when that field is patched.

Decode

decode restores URLSearchParams into complete values.

const values = codec.decode(new URLSearchParams('?q=router&page=2'));

Calling decode directly is more suitable for tests, fixtures, server-side logic, or utility code that needs to create typed values from existing URLSearchParams.