FieldCodec

FieldCodec describes how a single URL field maps to a business value. It is the smallest unit in Decurl's codec layer: Search Fields, URLSearchParams codecs, and React Router hooks all reuse the same field codec.

The core field of FieldCodec is decode. It decides how raw URL strings become business values, and it also determines the value type TypeScript can infer. The other fields are not another parsing system; they add URL key, write-back, default value, and equality semantics at specific moments.

Minimal Definition

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

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

decode is the only required field, and it can be a plain function. The smallest definition does not even need field(...): as long as the object satisfies the FieldCodec shape, it can be used by Search Fields and hooks.

In real code, field(...) is still recommended to freeze the codec type. It lets TypeScript check earlier whether mode, decode input, defaultValue, and encode match, and it avoids unnecessary widening when object literals are composed later.

Without defaultValue, the decoded field may be undefined; with defaultValue, the decoded field always has a value.

@guanriyue/decurl/decode provides composable helpers. Using them is not required by FieldCodec. They only reduce repeated code and make common parsing rules easier to read.

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,
});

Property Overview

Conceptually, a field codec can be understood as:

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;
};

The real type is a more precise union based on mode, whether defaultValue exists, and the decode output type, which gives better type inference.

PropertyRequiredTypeDescription
decodeYes(raw: TRaw) => TValue | null | undefinedUsed when reading the URL.
Converts a raw URL value into a business value. Returning null or undefined means the raw value is invalid; aliases continue to be tried, then Decurl falls back to defaultValue or undefined.
modeNo'single' | 'multi'Used when reading and writing the URL.
Defaults to 'single'. single reads one value. multi reads all values for the same key and writes multiple keys with the same name.
nameNostring | readonly string[]Used when Search Fields are fixed, read, and written.
Overrides the URL key. The first array item is the canonical key, and later items are legacy aliases. If omitted, the field property key is used.
defaultValueNoNonNullable<TValue>Used when decode fails or the key is missing, and also affects behavior when writing default values.
When present, the field always has a decoded value and type inference reflects that. Writing a value equal to the default omits the key from the URL by default.
encodeNo(value: NonNullable<TValue>) => TRaw | null | undefinedUsed when writing the URL.
By default, values are serialized with String(value). Provide it only when the business value needs a special serialization format. Returning null or undefined means the key is not written.
eqNo(left: NonNullable<TValue>, right: NonNullable<TValue>) => booleanUsed when comparing previous and next decoded values.
It determines whether values are equal and affects stable references returned by hooks, default value omission checks, and similar behavior. If omitted, single uses Object.is, and multi uses order-sensitive shallow array equality.

Read Timing

The difference between single and multi is the shape of the raw input passed to decode. A single field reads one URL value and decode receives string; a multi field reads all values with the same key and decode receives string[].

defaultValue is the fallback when a field cannot obtain a valid business value from the URL. When URLSearchParams does not contain the key, Decurl directly applies defaultValue and does not execute decode. When the key exists but decode returns null / undefined, Decurl also uses defaultValue. If decode throws, the expected behavior is also to fall back to defaultValue.

Most fields do not need custom encode or eq. Use encode only when values such as Date, objects, compressed strings, or special array formats need a custom write-back format. Use eq only when values such as objects, Date, or unordered arrays need business-level equality.

TypeScript

Type Inference

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 cannot be null or undefined. This restriction guarantees the type promise that "a field with a default value always has a business value".

Literal objects can also be inferred, but field(...) is recommended to freeze the codec type. That lets TypeScript check each field's mode, default value, and decode input shape earlier, and avoids unnecessary type widening during later composition.

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
>;

Hook Inference

useSearchValue reads a single named field codec. Usually it comes from the return value of 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 reads a group of field codecs. The Search Fields below cover single optional, multi optional, single required, and multi required fields.

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; };