URLSearchParams

createURLSearchParamsCodec 把 Search Fields 变成可直接使用的 URLSearchParams codec。

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

const codec = createURLSearchParamsCodec(searchFields);

它提供两个方法:

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

大多数情况下,开发者不需要主动调用 decode。日常业务通常会通过更上层的状态读取 API 获取 values。

encode 仍然很重要。当你需要构造链接、跳转目标、分享 URL、测试 fixture,或者在非 React Router 环境中生成 search string 时,encode 可以复用同一份 Search Fields,避免手写 query string。

Encode

encode 的输入来自 Search Fields。字段名和字段值都会被 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

这比手写 query string 更可维护:

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

手写字符串会绕过 Search Fields:key 是否正确、value 是否需要 encode、默认值是否应该省略,都需要调用方自己记住。

类型错误会在调用点暴露出来:

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 默认是 patch 语义。

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

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

Patch encode 的规则:

  • 只处理 patch 中出现的 Search Fields field。
  • patch 中不存在的 field 不会被修改。
  • patch 中不属于 Search Fields 的 key 会被忽略。
  • nullundefined 表示删除对应 URL key。
codec.encode(
  { keyword: undefined },
  { base: '?q=router&page=2' },
).toString();
// page=2

这不是 full update。URL search params 常常被多个组件或功能共享,patch encode 更适合局部更新。

base 表示当前 URLSearchParams。传入 base 时,encode 会从它的拷贝开始,再叠加 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

这对真实页面很重要,因为 URL 里可能有当前组件不负责的参数。Search Fields 只更新自己认识的字段,其余参数会被保留。

写回规则

Canonical key

当 field 有多个 name 时,第一项是 canonical key,后续项是 legacy alias。

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

写回时总是使用 canonical key。

const codec = createURLSearchParamsCodec(searchFields);

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

只要 patch 触碰该 field,encode 就会清理 legacy alias,避免新旧 key 同时留在 URL 中。

旧链接: ?p=2
新写回: ?page=3

Default value

写入等于 defaultValue 的值时,默认会删除对应 URL key,让 URL 保持简洁。

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

const codec = createURLSearchParamsCodec(searchFields);

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

如果希望默认值也出现在 URL 中,可以传入 preserveDefault: true

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

preserveDefault 是 encode 选项,不是 FieldCodec 属性。

Multi field

Multi field 会按数组顺序写入多个同名 key。

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

如果 multi field 有 alias,写回规则仍然一样:使用 canonical key,并在该 field 被 patch 时清理 legacy alias。

Decode

decode 用于把 URLSearchParams 还原成完整 values。

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

直接调用 decode 更适合测试、fixture、服务端逻辑,或需要从已有 URLSearchParams 生成 typed values 的工具代码。