Decode pipeline

FieldCodec 的 decode 可以是普通函数。@guanriyue/decurl/decode 提供的是一组 helper,用来把常见解析步骤写成更短、更显式、更容易 review 的 pipeline。

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

const decodePage = pipe(trim, shape.integer, toNumber, min(1));

这段 pipeline 表达了完整策略:先 trim,再要求整数形状,然后转换成 number,最后要求最小值为 1。

Decode step

每个 decode step 都是普通函数:

type Decode<Input, Output> = (
  input: Input,
) => Output | null | undefined;

返回 nullundefined 表示当前 step 解析失败、值缺失或值无效。pipe 遇到失败结果会停止执行后续步骤。

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

const decodePage = pipe(trim, shape.integer, toNumber, min(1));

decodePage(' 2 ');
// 2

decodePage('0');
// undefined

decodePage('abc');
// undefined

失败结果如何回退到 defaultValue,属于 FieldCodec 层的职责;decode primitive 只负责表达“这个 raw value 能不能变成业务值”。

先 shape,再转换

推荐先用 shape 限定字符串形状,再做转换。

import { pipe, shape, toNumber } from '@guanriyue/decurl/decode';

const decodeInteger = pipe(shape.integer, toNumber);
const decodeNumber = pipe(shape.number, toNumber);

这样可以避免把 URL 中意外出现的值隐式转换成业务值。

Number('');
// 0

Number('1e3');
// 1000

这些转换对普通 JavaScript 是合法的,但在 URL search fields 中通常不够可审查。Decode pipeline 倾向于先声明哪些字符串形状可以进入业务层。

条件约束

elementOf 适合枚举值:

import { elementOf } from '@guanriyue/decurl/decode';

const decodeSort = elementOf(['relevance', 'latest']);

decodeSort('latest');
// 'latest'

decodeSort('random');
// undefined

where 适合自定义 predicate 或 type guard:

import { where } from '@guanriyue/decurl/decode';

const decodeNonEmpty = where((value: string) => value.length > 0);

startsWithminmaxlength 这类 helper 也都只做单一步骤。它们的目标不是成为完整 validation DSL,而是让常见 URL search params 约束更容易组合。

多值字段

URLSearchParams 允许同一个 key 出现多次:

?tag=react&tag=router&tag=react

多值字段的 decode 输入通常是 string[]。可以用 mapItems 对每一项执行 pipe-like decode,并自动过滤解析失败的项。

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

const decodeTags = pipe(
  mapItems(elementOf(['react', 'router', 'docs'])),
  unique,
);

decodeTags(['react', 'unknown', 'router', 'react']);
// ['react', 'router']

mapItems 的职责是 item decode 和过滤 null / undefined。如果需要排序、分组或其他数组级业务规则,可以把普通函数接到 pipe 后面。

const decodeSortedTags = pipe(
  mapItems(elementOf(['react', 'router', 'docs'])),
  unique,
  (values) => values.toSorted(),
);

TypeScript

Decode primitive 会保留 pipeline 的类型信息。elementOf 可以从内联数组字面量推导联合类型,不需要 as const

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 decodeSort: Decode<unknown, "relevance" | "latest">
decodeSort
=
elementOf<readonly ["relevance", "latest"]>(values: readonly ["relevance", "latest"]): Decode<unknown, "relevance" | "latest"> (+1 overload)
elementOf
(['relevance', 'latest']);
const
const sort: "relevance" | "latest" | null | undefined
sort
=
const decodeSort: (input: unknown) => "relevance" | "latest" | null | undefined
decodeSort
('latest');
const
const decodePage: Decode<string, number>
decodePage
=
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));
const
const page: number | null | undefined
page
=
const decodePage: (input: string) => number | null | undefined
decodePage
('2');
const
const decodeTags: Decode<unknown[], ("react" | "router" | "docs")[]>
decodeTags
=
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
,
); const
const tags: ("react" | "router" | "docs")[] | null | undefined
tags
=
const decodeTags: (input: unknown[]) => ("react" | "router" | "docs")[] | null | undefined
decodeTags
(['react', 'docs']);

边界

Decode primitive 只关心普通值的转换、过滤和组合。它不理解 URLSearchParams key,也不负责 alias、defaultValue、patch encode 或 React Router hooks。

这些边界分别由后续文档说明:

  • FieldCodec 定义:单个字段如何使用 decode,以及 defaultValueencodeeq 如何发挥作用。
  • Search Fields:多个 field 如何组成一份 search object。
  • URLSearchParams:Search Fields 如何 decode 和 patch encode URLSearchParams。

为什么不是成品 parser

Decurl 不提供 parseAsInteger()parseAsDateRange() 这类成品 parser。URL 参数解析通常包含多个项目级选择,例如是否 trim、是否允许科学计数法、失败时如何处理。成品 parser 会把这些选择藏起来。

推荐把步骤写出来:

pipe(trim, shape.integer, toNumber)

这比一个宽泛的 parser 更容易局部调整:

pipe(trim, shape.integer, toNumber, min(1))

业务项目如果需要更短的写法,可以在项目内封装自己的函数:

const positiveInteger = pipe(trim, shape.integer, toNumber, min(1));

这种封装带有业务上下文,比在通用 decode primitive 中隐藏策略更可控。

为什么使用它

自定义函数完全没问题。Decode primitive 不是 FieldCodec 的必需品,也不是 Decurl 强加给开发者的写法。你可以直接在 decode 里写任意解析逻辑;当数据结构复杂、解析步骤带有明显业务语义时,也更推荐把逻辑写成自己的函数。

Decode primitive 只是一个备选。它主要服务常见且简单的 URL search params 场景,例如分页、排序、筛选项、开关状态、资源 id。它更像一组描述性语言:用边界明确的小函数,把“这个 URL 字符串如何进入业务层”写出来。

pipe(trim, shape.integer, toNumber, min(1))

这段代码比手写函数更短,但仍然能看到每一步。它不会偷偷 trim、不会偷偷接受宽松数字格式,也不会把 fallback 或 default 策略藏进 parser 里。

如果熟悉函数式编程,可以把 decode primitive 粗略类比为 Optional / Maybe 这类 monad 风格组合:每一步接收一个值,产出下一个值;null / undefined 被约定为“无效值”,后续步骤停止。这不是为了引入复杂概念,而是为了让小型解析部件可预测、可组合。

这也是为什么它比较接近 RxJS operator 或 lodash 小函数的设计取向:每个函数小而明确,组合后提升可读性。但 Decurl 会控制 primitive 的数量,不把它扩展成庞大的工具库。人类开发者难以记忆那么多工具函数。但在 AI coding 越来越常见的场景下,小型、命名清晰、边界稳定的 API 更容易被 agent 扫描和使用,也更容易被开发者 review。