Search Fields

Search Fields are a group of field codecs with fixed URL keys. They describe which URL search params a page or feature area cares about, and how those params are read, written, and inferred.

defineFields is not responsible for running decode or encode. Its job is to establish determinism at definition time:

  • Freeze URL keys for fields without name.
  • Preserve canonical keys and legacy aliases.
  • Let a single field be used directly by hooks.
  • Preserve type inference for the whole search values object.

See FieldCodec for FieldCodec properties, and Decode pipeline for decode helper composition.

defineFields

defineFields accepts a group of field codecs and returns a group of named field codecs.

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

const searchFields = defineFields({
  keyword: field({
    name: 'q',
    decode: pipe(trim, shape(/.+/)),
  }),
  page: field({
    name: ['page', 'p'],
    decode: pipe(trim, shape.integer, toNumber, min(1)),
    defaultValue: 1,
  }),
  sort: field({
    decode: elementOf(['relevance', 'latest']),
    defaultValue: 'relevance',
  }),
});

In this definition:

  • keyword explicitly uses the URL key q.
  • page uses page as the canonical key and supports the old key p.
  • sort does not provide name, so it uses the field property key, which is sort.

Freezing Names

defineFields freezes each field's URL key into a non-empty name:

  • If name is not provided, the field property key is used.
  • If name is a string, it is used directly as the URL key.
  • If name is a non-empty array, the first item is the canonical key and the rest are legacy aliases.
  • If name is an empty array, Decurl falls back to the field property key.

The first frozen item is the canonical key. Later items are legacy aliases, mainly for compatibility with old URLs.

If a developer has already provided an explicit name, defineFields is not required. A single named field codec can be passed directly to useSearchValue.

defineFields is better suited for defining page-level Search Fields: it freezes missing name values in bulk and checks duplicate URL keys in development. For page search params maintained by multiple people, it is still recommended to define fields in one place with defineFields.

Named Field

Search Fields let a single field codec carry its URL key, so upper-level APIs do not need another magic string.

import { useSearchValue } from '@guanriyue/decurl';

const SearchInput = () => {
  const [keyword, setKeyword] = useSearchValue(searchFields.keyword);

  return (
    <input
      value={keyword ?? ''}
      onChange={(event) => {
        setKeyword(event.currentTarget.value);
      }}
    />
  );
};

This is easier to maintain than this API shape:

useSearchValue('q', keywordCodec);

String keys are hard to debug once mistyped, while searchFields.keyword carries both the decode rule and the URL key.

Alias Migration

Aliases are used to support old links.

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

When reading, Decurl tries page first, then p.

When writing, it only uses the first name, the canonical key page.

This gives the following behavior:

Old link: ?p=2
New write-back: ?page=3

TypeScript

A field codec with an explicit name can be passed directly to useSearchValue:

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

If a field codec does not contain name, a single-field hook cannot know which URL key to read or write, and TypeScript prevents that usage:

import { 
const useSearchValue: <TCodec extends NamedFieldCodec>(codec: TCodec) => UseSearchValueResult<TCodec>
useSearchValue
} from '@guanriyue/decurl';
useSearchValue<NamedFieldCodec>(codec: NamedFieldCodec): UseSearchValueResult<NamedFieldCodec>
useSearchValue
({
Argument of type '{ decode: (x: string) => number; }' is not assignable to parameter of type 'NamedFieldCodec'. Type '{ decode: (x: string) => number; }' is not assignable to type 'WithDefinedFieldName<MultiRequiredFieldCodec<any>>'. Property 'name' is missing in type '{ decode: (x: string) => number; }' but required in type '{ name: DefinedFieldName; }'.
decode: (x: string) => number
decode
:
x: string
x
=>
var Number: NumberConstructor
(value?: any) => number

An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.

Number
(
x: string
x
)
});

A group of Search Fields can continue to infer the full values type through InferFieldValues:

import type { 
type InferFieldValues<TDefinition extends RecordCodec> = { [key in keyof TDefinition]: InferFieldValue<TDefinition[key]>; } extends any ? { [key in keyof { [key in keyof TDefinition]: InferFieldValue<TDefinition[key]>; }]: { [key in keyof TDefinition]: InferFieldValue<TDefinition[key]>; }[key]; } : never
InferFieldValues
} from '@guanriyue/decurl/codec';
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
,
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<WithDefinedFieldName<SingleRequiredFieldCodec<number>>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}
searchFields
=
defineFields<{
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
    sort: SingleRequiredFieldCodec<"relevance" | "latest">;
}>(definition: {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<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
:
pipe<string, string, string>(ab: Decode<string, string>, bc: Decode<string, string>): Decode<string, string> (+9 overloads)
pipe
(
module trim
const trim: {
    (input: string): string;
    start(input: string): string;
    end(input: string): string;
}
trim
,
function shape(regexp: RegExp): Decode<string, string>
shape
(/.+/)),
}),
page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>
page
:
field<number>(definition: FieldCodecBase<number> & {
    mode?: "single";
    decode: Decode<string, number>;
    encode?: ((value: number) => string | null | undefined) | undefined;
    defaultValue: number;
} & {
    name: DefinedFieldName;
}): WithDefinedFieldName<SingleRequiredFieldCodec<number>> (+7 overloads)
field
({
name: (FieldName | undefined) & DefinedFieldName
name
: ['page', 'p'],
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',
}), }); type
type SearchValues = {
    keyword: string | undefined;
    page: number;
    sort: NonNullable<"relevance" | "latest" | null | undefined>;
}
SearchValues
=
type InferFieldValues<TDefinition extends RecordCodec> = { [key in keyof TDefinition]: InferFieldValue<TDefinition[key]>; } extends any ? { [key in keyof { [key in keyof TDefinition]: InferFieldValue<TDefinition[key]>; }]: { [key in keyof TDefinition]: InferFieldValue<TDefinition[key]>; }[key]; } : never
InferFieldValues
<typeof
const searchFields: {
    keyword: WithDefinedFieldName<WithDefinedFieldName<SingleOptionalFieldCodec<string>>>;
    page: WithDefinedFieldName<WithDefinedFieldName<SingleRequiredFieldCodec<number>>>;
    sort: WithDefinedFieldName<SingleRequiredFieldCodec<"relevance" | "latest">>;
}
searchFields
>;

Business code does not need to manually write another search values type. Search Fields are the shared source for URL field mapping and TypeScript values.

Name Conflicts

Multiple fields in the same Search Fields group should not share the same URL key.

defineFields({
  page: field({ name: ['page', 'p'], decode: decodePage }),
  currentPage: field({ name: 'p', decode: decodePage }),
});

This makes decode and encode ambiguous. Decurl warns about duplicate names in development to help catch field definition problems early.

Why Not Dynamic Keys

Decurl does not support passing the URL key and field codec separately:

useSearchValue('test', searchFields.keyword);

This looks more flexible, but typos are hard to debug. Similar words such as test and text are not something the type system can analyze; without assistance from intelligent analysis tools such as AI, they are usually hard to notice during review. URL search params are fundamentally string parsing and serialization. For a page or feature area, writing rules as explicit static Search Fields is easier to maintain than assembling them dynamically at call sites.

Freezing name also means the same field codec object is not suitable for reuse across multiple URL keys. This is an intentional trade-off: Decurl values explicit field definitions and clear boundaries more than saving a few lines with the same codec object. Reusing codec objects may cause different pages or fields to share URL semantics that should not be shared, and those problems are usually harder to debug.

If you need reuse, prefer reusing smaller functions, such as decode primitives or custom business decode helpers:

const decodeKeyword = pipe(trim, shape(/.+/));

const searchFields = defineFields({
  keyword: field({
    name: 'q',
    decode: decodeKeyword,
  }),
  fallbackKeyword: field({
    name: 'keyword',
    decode: decodeKeyword,
  }),
});

This reuses parsing logic while keeping ownership of each URL key clear.

Boundaries

Search Fields only freeze field mappings. They do not explain a single field's defaultValue, encode, or eq, and they do not actually read or write URLSearchParams.

Those topics are covered by:

  • FieldCodec: how a single field decodes, encodes, sets default values, and compares values.
  • URLSearchParams: how Search Fields are compiled into a { decode, encode } codec.