Decode pipeline

The decode of a FieldCodec can be a plain function. @guanriyue/decurl/decode provides helpers that write common parsing steps as shorter, more explicit, and easier-to-review pipelines.

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

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

This pipeline expresses the full strategy: trim first, require an integer shape, convert to number, then require the minimum value to be 1.

Decode Step

Every decode step is a plain function:

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

Returning null or undefined means the current step failed, the value is missing, or the value is invalid. When pipe sees a failed result, it stops later steps.

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

How failed results fall back to defaultValue belongs to the FieldCodec layer. Decode primitives only express whether "this raw value can become a business value".

Shape Before Convert

Prefer using shape to constrain the string shape before converting.

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

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

This avoids implicitly converting unexpected URL values into business values.

Number('');
// 0

Number('1e3');
// 1000

These conversions are legal JavaScript, but they are usually not reviewable enough for URL search fields. Decode pipelines prefer to first declare which string shapes are allowed to enter the business layer.

Conditional Constraints

elementOf fits enum-like values:

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

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

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

decodeSort('random');
// undefined

where fits custom predicates or type guards:

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

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

Helpers such as startsWith, min, max, and length also do only one step. Their goal is not to become a full validation DSL, but to make common URL search params constraints easier to compose.

Multi-value Fields

URLSearchParams allows the same key to appear multiple times:

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

The decode input for a multi-value field is usually string[]. Use mapItems to run pipe-like decode logic for each item and automatically filter out failed items.

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 is responsible for item decode and filtering null / undefined. If you need sorting, grouping, or other array-level business rules, attach a plain function after it in pipe.

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

TypeScript

Decode primitives preserve pipeline type information. elementOf can infer a union type from an inline array literal; as const is not required.

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']);

Boundaries

Decode primitives only care about normal value conversion, filtering, and composition. They do not understand URLSearchParams keys, and they are not responsible for aliases, defaultValue, patch encode, or React Router hooks.

Those boundaries are described in later documents:

  • FieldCodec: how a single field uses decode, and how defaultValue, encode, and eq work.
  • Search Fields: how multiple fields form one search object.
  • URLSearchParams: how Search Fields decode and patch encode URLSearchParams.

Why Not Ready-made Parsers

Decurl does not provide ready-made parsers such as parseAsInteger() or parseAsDateRange(). URL parameter parsing usually includes multiple project-level choices, such as whether to trim, whether to allow scientific notation, and how to handle failures. Ready-made parsers hide those choices.

Prefer writing the steps out:

pipe(trim, shape.integer, toNumber)

This is easier to adjust locally than a broad parser:

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

If a project needs shorter syntax, wrap your own helper inside the project:

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

That wrapper carries business context and is more controllable than hiding the strategy inside a general decode primitive.

Why Use Them

Custom functions are completely fine. Decode primitives are not required by FieldCodec, and Decurl does not force this style on developers. You can write any parsing logic directly in decode; when the data structure is complex or the parsing steps carry clear business semantics, writing your own function is often better.

Decode primitives are only an option. They mainly serve common and simple URL search params scenarios, such as pagination, sorting, filters, toggle states, and resource ids. They behave more like a small descriptive language: use small functions with clear boundaries to write down "how this URL string enters the business layer".

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

This code is shorter than a hand-written function, but each step is still visible. It does not secretly trim, does not secretly accept loose numeric formats, and does not hide fallback or default strategies inside a parser.

If you are familiar with functional programming, decode primitives are loosely comparable to Optional / Maybe-style monadic composition: each step receives one value and produces the next value; null / undefined are treated as "invalid value", and later steps stop. The point is not to introduce complex concepts, but to keep small parsing pieces predictable and composable.

This is why the design is close to RxJS operators or small lodash functions: each function is small and explicit, and composition improves readability. Decurl will still keep the number of primitives under control instead of growing into a huge utility library. Human developers struggle to remember that many helpers. But as AI coding becomes more common, small APIs with clear names and stable boundaries are easier for agents to scan and use, and easier for developers to review.