FieldCodec 定义

FieldCodec 描述单个 URL 字段如何映射到业务值。它是 Decurl codec 层的最小单元:Search Fields、URLSearchParams codec 和 React Router hooks 都会复用同一份 field codec。

FieldCodec 的核心字段是 decode。它决定 URL 原始字符串如何变成业务值,也决定 TypeScript 能推导出的值类型。其他字段不是另一套解析规则,而是在特定时机补充 URL key、写回、默认值和相等性语义。

最小定义

const keyword = {
  decode: (value) => {
    const nextValue = value.trim();

    return nextValue === '' ? undefined : nextValue;
  },
};

decode 是唯一必填字段,它可以是普通函数。最简定义甚至不需要调用 field(...):只要对象满足 FieldCodec 形状,就可以被 Search Fields 和 hooks 使用。

不过在实际代码中,仍然推荐使用 field(...) 固化 codec 类型。它能让 TypeScript 更早检查 modedecode 输入、defaultValueencode 是否匹配,也能避免对象字面量在后续组合时被不必要地拓宽。

没有 defaultValue 时,字段 decode 后可能是 undefined;带 defaultValue 时,字段 decode 后一定有值。

@guanriyue/decurl/decode 提供的是一组可组合的 helper。使用它们不是 FieldCodec 的要求,只是为了减少重复编码,并让常见解析规则更容易阅读。

import { field } from '@guanriyue/decurl/codec';
import { min, pipe, shape, toNumber, trim } from '@guanriyue/decurl/decode';

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

属性概览

概念上,一个 field codec 可以理解为:

type FieldCodec<TValue, TRaw extends string | string[] = string> = {
  mode?: 'single' | 'multi';
  name?: string | readonly string[];
  decode: (raw: TRaw) => TValue | null | undefined;
  encode?: (value: NonNullable<TValue>) => TRaw | null | undefined;
  defaultValue?: NonNullable<TValue>;
  eq?: (left: NonNullable<TValue>, right: NonNullable<TValue>) => boolean;
};

实际类型会根据 mode、是否存在 defaultValuedecode 输出类型拆成更精确的 union,以获得更好的类型推导。

属性必填类型说明
decode(raw: TRaw) => TValue | null | undefined读取 URL 时发挥作用。
把 URL 中的 raw value 转成业务值;返回 nullundefined 表示当前 raw value 无效,会继续尝试 alias,最后回退到 defaultValueundefined
mode'single' | 'multi'读取和写入 URL 时发挥作用。
默认是 'single'single 读取一个值,multi 读取同一个 key 的所有值,并在写入时生成多个同名 key。
namestring | readonly string[]Search Fields 固化、读取、写入时发挥作用。
覆盖 URL key;数组第一项是 canonical key,后续项是 legacy alias。没有提供时使用 field property key。
defaultValueNonNullable<TValue>decode 失败或 key 缺失时发挥作用,也会影响写入默认值时的行为。
存在时字段 decode 后一定有值,并影响类型推导;写入等于默认值的值时,默认会从 URL 中省略这个 key。
encode(value: NonNullable<TValue>) => TRaw | null | undefined写入 URL 时发挥作用。
默认会用 String(value) 序列化;只有业务值需要特殊序列化时才需要提供。返回 nullundefined 表示不写入该 key。
eq(left: NonNullable<TValue>, right: NonNullable<TValue>) => boolean比较前后 decoded value 时发挥作用。
用于判断值是否相等,影响 hooks 返回值的稳定引用、默认值省略判断等。未提供时,single 使用 Object.ismulti 使用顺序敏感的 shallow array equality。

读取时机

singlemulti 的区别在于传给 decode 的 raw input 形状。single 字段读取一个 URL value,decode 接收 stringmulti 字段读取同一个 key 的所有 values,decode 接收 string[]

defaultValue 是字段无法从 URL 得到有效业务值时的 fallback。URLSearchParams 没有对应 key 时会直接使用 defaultValue,不会执行 decode;key 存在但 decode 返回 null / undefined 时,也会使用 defaultValue。如果 decode 抛出错误,预期行为同样是回退到 defaultValue

大多数字段不需要自定义 encodeeq。只有 Date、对象、压缩字符串、特殊数组序列化这类值需要自定义写回格式时,才需要 encode;只有对象、Date、无序数组这类值需要业务相等性时,才需要 eq

TypeScript

类型推导

import type { 
type InferFieldValue<TCodec> = TCodec extends {
    defaultValue: {};
    decode: AnyFunction;
} ? NonNullable<ReturnType<TCodec["decode"]>> : TCodec extends OptionalFieldCodec<infer TValue> ? NonNullable<TValue> | undefined : never
InferFieldValue
} from '@guanriyue/decurl/codec';
import {
function field<TValue>(definition: SingleOptionalFieldCodec<TValue> & {
    name: DefinedFieldName;
}): WithDefinedFieldName<SingleOptionalFieldCodec<TValue>> (+7 overloads)
field
} from '@guanriyue/decurl/codec';
import {
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 keyword: SingleOptionalFieldCodec<string>
keyword
=
field<string>(definition: SingleOptionalFieldCodec<string>): SingleOptionalFieldCodec<string> (+7 overloads)
field
({
decode: Decode<string, string>
decode
:
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
,
}); type
type Keyword = string | undefined
Keyword
=
type InferFieldValue<TCodec> = TCodec extends {
    defaultValue: {};
    decode: AnyFunction;
} ? NonNullable<ReturnType<TCodec["decode"]>> : TCodec extends OptionalFieldCodec<infer TValue> ? NonNullable<TValue> | undefined : never
InferFieldValue
<typeof
const keyword: SingleOptionalFieldCodec<string>
keyword
>;
const
const 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,
}); type
type Page = number
Page
=
type InferFieldValue<TCodec> = TCodec extends {
    defaultValue: {};
    decode: AnyFunction;
} ? NonNullable<ReturnType<TCodec["decode"]>> : TCodec extends OptionalFieldCodec<infer TValue> ? NonNullable<TValue> | undefined : never
InferFieldValue
<typeof
const page: SingleRequiredFieldCodec<number>
page
>;

defaultValue 不能是 nullundefined。这条限制保证了“有默认值就一定有业务值”的类型承诺。

字面量对象也可以被推导,但推荐使用 field(...) 固化 codec 类型。这样每个字段的模式、默认值和 decode 输入形状会更早被 TypeScript 检查,也能避免对象被后续组合时发生不必要的类型拓宽。

import type { 
type InferFieldValue<TCodec> = TCodec extends {
    defaultValue: {};
    decode: AnyFunction;
} ? NonNullable<ReturnType<TCodec["decode"]>> : TCodec extends OptionalFieldCodec<infer TValue> ? NonNullable<TValue> | undefined : never
InferFieldValue
} from '@guanriyue/decurl/codec';
import {
function field<TValue>(definition: SingleOptionalFieldCodec<TValue> & {
    name: DefinedFieldName;
}): WithDefinedFieldName<SingleOptionalFieldCodec<TValue>> (+7 overloads)
field
} from '@guanriyue/decurl/codec';
import {
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
} from '@guanriyue/decurl/decode';
const
const literalCodec: {
    decode: {
        (input: string): string;
        start(input: string): string;
        end(input: string): string;
    };
}
literalCodec
= {
decode: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
decode
:
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
,
}; type
type LiteralValue = string | undefined
LiteralValue
=
type InferFieldValue<TCodec> = TCodec extends {
    defaultValue: {};
    decode: AnyFunction;
} ? NonNullable<ReturnType<TCodec["decode"]>> : TCodec extends OptionalFieldCodec<infer TValue> ? NonNullable<TValue> | undefined : never
InferFieldValue
<typeof
const literalCodec: {
    decode: {
        (input: string): string;
        start(input: string): string;
        end(input: string): string;
    };
}
literalCodec
>;
const
const stableCodec: SingleOptionalFieldCodec<string>
stableCodec
=
field<string>(definition: SingleOptionalFieldCodec<string>): SingleOptionalFieldCodec<string> (+7 overloads)
field
({
decode: Decode<string, string>
decode
:
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
,
}); type
type StableValue = string | undefined
StableValue
=
type InferFieldValue<TCodec> = TCodec extends {
    defaultValue: {};
    decode: AnyFunction;
} ? NonNullable<ReturnType<TCodec["decode"]>> : TCodec extends OptionalFieldCodec<infer TValue> ? NonNullable<TValue> | undefined : never
InferFieldValue
<typeof
const stableCodec: SingleOptionalFieldCodec<string>
stableCodec
>;

Hooks 推导

useSearchValue 读取单个 named field codec。通常它来自 defineFields 的返回值。

import { 
const useSearchValue: <TCodec extends NamedFieldCodec>(codec: TCodec) => UseSearchValueResult<TCodec>
useSearchValue
} from '@guanriyue/decurl';
import {
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 {
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
} from '@guanriyue/decurl/decode';
const
const fields: {
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
}
fields
=
defineFields<{
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
}>(definition: {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
}): {
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
}
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
,
}), }); const
const SearchInput: () => null
SearchInput
= () => {
const [
const keyword: string | undefined
keyword
,
const setKeyword: SetSearchValue<WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>>
setKeyword
] =
useSearchValue<WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>>(codec: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>): UseSearchValueResult<WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>>
useSearchValue
(
const fields: {
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
}
fields
.
keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>
keyword
);
const keyword: string | undefined
keyword
;
const setKeyword: SetSearchValue<WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>>
setKeyword
;
return null; };

useSearchValues 读取一组 field codec。下面的 Search Fields 同时覆盖了 single optional、multi optional、single required 和 multi required 四种 field。

import { 
const useSearchValues: <TDefinition extends RecordCodec>(schema: TDefinition) => UseSearchValuesResult<TDefinition>
useSearchValues
} from '@guanriyue/decurl';
import {
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
,
function mapItems<A, B>(ab: Decode<A, B>): Decode<A[], B[]> (+9 overloads)
mapItems
,
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
,
const unique: Unique
unique
} from '@guanriyue/decurl/decode';
const
const fields: {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    tags: WithDefinedFieldName<MultiOptionalFieldCodec<("react" | "router" | "docs")[]>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    views: WithDefinedFieldName<MultiRequiredFieldCodec<("list" | "grid")[]>>;
}
fields
=
defineFields<{
    keyword: SingleOptionalFieldCodec<string>;
    tags: MultiOptionalFieldCodec<("react" | "router" | "docs")[]>;
    page: SingleRequiredFieldCodec<number>;
    views: MultiRequiredFieldCodec<("list" | "grid")[]>;
}>(definition: {
    keyword: SingleOptionalFieldCodec<string>;
    tags: MultiOptionalFieldCodec<("react" | "router" | "docs")[]>;
    page: SingleRequiredFieldCodec<number>;
    views: MultiRequiredFieldCodec<("list" | "grid")[]>;
}): {
    ...;
}
defineFields
({
keyword: SingleOptionalFieldCodec<string>
keyword
:
field<string>(definition: SingleOptionalFieldCodec<string>): SingleOptionalFieldCodec<string> (+7 overloads)
field
({
decode: Decode<string, string>
decode
:
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
,
}),
tags: MultiOptionalFieldCodec<("react" | "router" | "docs")[]>
tags
:
field<("react" | "router" | "docs")[]>(definition: MultiOptionalFieldCodec<("react" | "router" | "docs")[]>): MultiOptionalFieldCodec<("react" | "router" | "docs")[]> (+7 overloads)
field
({
mode: "multi"
mode
: 'multi',
decode: Decode<string[], ("react" | "router" | "docs")[]>
decode
:
pipe<unknown[], ("react" | "router" | "docs")[], ("react" | "router" | "docs")[]>(ab: Decode<unknown[], ("react" | "router" | "docs")[]>, bc: Decode<("react" | "router" | "docs")[], ("react" | "router" | "docs")[]>): Decode<unknown[], ("react" | "router" | "docs")[]> (+9 overloads)
pipe
(
mapItems<unknown, "react" | "router" | "docs">(ab: Decode<unknown, "react" | "router" | "docs">): Decode<unknown[], ("react" | "router" | "docs")[]> (+9 overloads)
mapItems
(
elementOf<readonly ["react", "router", "docs"]>(values: readonly ["react", "router", "docs"]): Decode<unknown, "react" | "router" | "docs"> (+1 overload)
elementOf
(['react', 'router', 'docs'])),
const unique: Unique
unique
),
}),
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,
}),
views: MultiRequiredFieldCodec<("list" | "grid")[]>
views
:
field<("list" | "grid")[]>(definition: MultiRequiredFieldCodec<("list" | "grid")[]>): MultiRequiredFieldCodec<("list" | "grid")[]> (+7 overloads)
field
({
mode: "multi"
mode
: 'multi',
decode: Decode<string[], ("list" | "grid")[]>
decode
:
pipe<unknown[], ("list" | "grid")[], ("list" | "grid")[]>(ab: Decode<unknown[], ("list" | "grid")[]>, bc: Decode<("list" | "grid")[], ("list" | "grid")[]>): Decode<unknown[], ("list" | "grid")[]> (+9 overloads)
pipe
(
mapItems<unknown, "list" | "grid">(ab: Decode<unknown, "list" | "grid">): Decode<unknown[], ("list" | "grid")[]> (+9 overloads)
mapItems
(
elementOf<readonly ["list", "grid"]>(values: readonly ["list", "grid"]): Decode<unknown, "list" | "grid"> (+1 overload)
elementOf
(['list', 'grid'])),
const unique: Unique
unique
),
defaultValue: ("list" | "grid")[]
defaultValue
: ['list'],
}), }); const
const SearchPanel: () => null
SearchPanel
= () => {
const [
const values: {
    keyword: string | undefined;
    tags: ("react" | "router" | "docs")[] | undefined;
    page: number;
    views: ("list" | "grid")[];
}
values
,
const setValues: SetSearchValues<{
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    tags: WithDefinedFieldName<MultiOptionalFieldCodec<("react" | "router" | "docs")[]>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    views: WithDefinedFieldName<MultiRequiredFieldCodec<("list" | "grid")[]>>;
}>
setValues
] =
useSearchValues<{
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    tags: WithDefinedFieldName<MultiOptionalFieldCodec<("react" | "router" | "docs")[]>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    views: WithDefinedFieldName<MultiRequiredFieldCodec<("list" | "grid")[]>>;
}>(schema: {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    tags: WithDefinedFieldName<MultiOptionalFieldCodec<("react" | "router" | "docs")[]>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    views: WithDefinedFieldName<MultiRequiredFieldCodec<("list" | "grid")[]>>;
}): UseSearchValuesResult<...>
useSearchValues
(
const fields: {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    tags: WithDefinedFieldName<MultiOptionalFieldCodec<("react" | "router" | "docs")[]>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    views: WithDefinedFieldName<MultiRequiredFieldCodec<("list" | "grid")[]>>;
}
fields
);
const values: {
    keyword: string | undefined;
    tags: ("react" | "router" | "docs")[] | undefined;
    page: number;
    views: ("list" | "grid")[];
}
values
;
const setValues: SetSearchValues<{
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    tags: WithDefinedFieldName<MultiOptionalFieldCodec<("react" | "router" | "docs")[]>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    views: WithDefinedFieldName<MultiRequiredFieldCodec<("list" | "grid")[]>>;
}>
setValues
;
return null; };