@guanriyue/decurl/routeSpec

@guanriyue/decurl/routeSpec defines React Router paths and Search Fields in one place, then generates type-safe in-app hrefs from business values.

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

RouteSpec is a URL contract and href builder. It does not define components, loaders, actions, or route hierarchy.

routeSpec

Call Signature

routeSpec({
  path,
  search?,
})

The definition below preserves the concrete types of the path pattern and Search Fields:

import { defineFields, field } from '@guanriyue/decurl/codec';
import { routeSpec } from '@guanriyue/decurl/routeSpec';

const usersSearch = defineFields({
  keyword: field({
    decode: (input) => input || undefined,
  }),
  page: field({
    decode: (input) => {
      const value = Number(input);

      return Number.isSafeInteger(value) && value >= 1
        ? value
        : undefined;
    },
    defaultValue: 1,
  }),
});

const to = {
  users: routeSpec({
    path: '/users',
    search: usersSearch,
  }),
  userDetail: routeSpec({
    path: '/users/:id',
  }),
};

Options

ParameterTypeRequiredDefaultDescription
path`/${string}`Yes-React Router path pattern.
searchRecordCodecNoundefinedSearch Fields used to generate search params.

Semantically, path contains only the pathname pattern. It should not contain search or hash.

Return Value

routeSpec returns a callable object:

MemberDescription
spec(input?)Generates href with flat input.
spec.pathOriginal React Router path pattern, preserving the literal type.
spec.searchOriginal Search Fields; undefined when not defined.
spec.hrefByParts(input)Passes path params and search input separately, then generates href.

The result is an in-app href string:

to.userDetail({ id: 42 });
// /users/42

to.users({ keyword: 'type safe', page: 2 });
// /users?keyword=type+safe&page=2

If no search params are generated, the result does not append ?.

Whether Input Is Required

RouteSpec decides whether callable input is required based on path params and search input:

const home = routeSpec({ path: '/' });
const categories = routeSpec({ path: '/:lang?/categories' });

home();
categories();
categories({ lang: 'zh' });
to.users();
to.userDetail({ id: 42 });
  • When there are no path params and no search, input is not accepted.
  • When there are only optional path params or search, input can be omitted.
  • When there is a required path param, input is required.

Path Params

Path param inference and pathname generation follow React Router path patterns:

FormExampleInput
Required param/users/:idid: string | number | boolean
Optional param/:lang?/categorieslang?: string | number | boolean | null | undefined
Splat/files/*'*': string | number | boolean

Non-nullish path params are converted with String(value) first, then passed to React Router generatePath. Pathname param encoding follows React Router. RouteSpec does not reimplement path encoding:

const files = routeSpec({ path: '/files/*' });

files({ '*': 'docs/getting-started.md' });
// /files/docs/getting-started.md

search directly reuses Search Fields. When generating hrefs, RouteSpec only passes the search input to the same codec encode capability. It does not redefine search encoding rules.

to.users({ keyword: '@guanriyue/decurl', page: 2 });

Search input is a partial object. Which business values a field accepts, and how they are written to the URL, are determined by the corresponding FieldCodec.

RouteSpec does not decode search. When reading the URL, a page can directly reuse the Search Fields stored on the spec:

const UsersPage = () => {
  const [search] = useSearchValues(to.users.search);

  return <UsersTable search={search} />;
};

Flat Input

When calling RouteSpec directly, path params and search properties are merged into one flat object:

const userDetail = routeSpec({
  path: '/users/:id',
  search: defineFields({
    tab: field({
      decode: (input) => input || undefined,
    }),
  }),
});

userDetail({
  id: 42,
  tab: 'profile',
});

Conflict detection is based on path param names and Search Fields property keys. If the two parts contain the same property, flat input is disabled at the type level and you must use hrefByParts.

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 {
const routeSpec: <const TPath extends RouteSpecPath, const TSearch extends RecordCodec | undefined = undefined>(options: RouteSpecOptions<TPath, TSearch>) => RouteSpec<TPath, RouteSpecPathParams<TPath>, TSearch>

定义一个可调用的路由规格。

调用返回由 path params 和 search params 共同生成的 href,同时保留原始 path pattern、search definition 和分组生成 API。

@example
const userDetail = routeSpec({
  path: '/users/:id',
  search: userSearch,
})

userDetail({ id: 1, tab: 'profile' })
userDetail.path
routeSpec
} from '@guanriyue/decurl/routeSpec';
const
const detail: RouteSpec<"/users/:id", {
    id: string | number | boolean;
}, {
    id: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
}, never>
detail
=
routeSpec<"/users/:id", {
    id: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
}>(options: RouteSpecOptions<"/users/:id", {
    id: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
}>): RouteSpec<"/users/:id", {
    id: string | number | boolean;
}, {
    id: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
}, never>

定义一个可调用的路由规格。

调用返回由 path params 和 search params 共同生成的 href,同时保留原始 path pattern、search definition 和分组生成 API。

@example
const userDetail = routeSpec({
  path: '/users/:id',
  search: userSearch,
})

userDetail({ id: 1, tab: 'profile' })
userDetail.path
routeSpec
({
path: "/users/:id"

React Router path pattern。

path
: '/users/:id',
search?: {
    id: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
} | undefined

用于编码和解码 search params 的字段定义。

search
:
defineFields<{
    id: SingleOptionalFieldCodec<string>;
}>(definition: {
    id: SingleOptionalFieldCodec<string>;
}): {
    id: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
}
defineFields
({
id: SingleOptionalFieldCodec<string>
id
:
field<string>(definition: SingleOptionalFieldCodec<string>): SingleOptionalFieldCodec<string> (+7 overloads)
field
({
decode: Decode<string, string>
decode
: (
input: string
input
) =>
input: string
input
||
var undefined
undefined
,
}), }), });
const detail: RouteSpec
(input: never) => string
detail
({ id: 42 });
Argument of type '{ id: number; }' is not assignable to parameter of type 'never'.

hrefByParts

hrefByParts accepts path params and search input separately. It is the unambiguous grouped href generation API:

const detail = routeSpec({
  path: '/users/:id',
  search: defineFields({
    id: field({
      decode: (input) => input || undefined,
    }),
  }),
});

detail.hrefByParts({
  params: { id: 42 },
  search: { id: 'filter-id' },
});
// /users/42?id=filter-id
PartRequired RuleDescription
paramsRequired when required path params existUsed only to generate the pathname.
searchNoUsed only to generate search params.

When optional parts are omitted, runtime treats them as empty objects. The hrefByParts input object itself is always required.

Working with React Router

RouteSpec returns strings that can be passed directly to React Router:

<Link to={to.userDetail({ id: userId })}>
  Detail
</Link>
const navigate = useNavigate();

navigate(to.users({ keyword: '@guanriyue/decurl' }));

Router config

Router config can reuse the path pattern stored on RouteSpec:

const routes = [
  {
    path: to.users.path,
    element: <UsersPage />,
  },
  {
    path: to.userDetail.path,
    element: <UserDetailPage />,
  },
];

RouteSpec does not store parent/child relationships. Object or directory hierarchy does not create extra route semantics.

Types

Public type extractors correspond to path params, search encode input, and search decode values:

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 { type
type InferRouteSpecParams<TSpec> = RouteSpecAssociatedTypesOf<TSpec> extends {
    params: infer TParams;
} ? TParams : never

提取 RouteSpec 生成 pathname 时接受的 path params 类型。

该类型会保留 RouteSpec 对 path params 的显式类型约束,适合用于封装导航函数、 预加载函数或其他需要单独接收 path params 的模块边界。

@example
const userDetail = routeSpec({
  path: '/users/:id',
})

type UserDetailParams = InferRouteSpecParams<typeof userDetail>

function preloadUser(params: UserDetailParams) {
  return queryClient.prefetchQuery({
    queryKey: ['user', params.id],
  })
}
InferRouteSpecParams
,
type
type InferRouteSpecSearchInput<TSpec> = RouteSpecAssociatedTypesOf<TSpec> extends {
    searchInput: infer TInput;
} ? TInput : never

提取 RouteSpec 生成 href 时接受的 search 输入类型。

这是从业务值写入 URL 的输入类型。字段可以按需传入,并允许使用 nullundefined 表示不写入或移除对应的 search param。它适合用于搜索表单、导航 函数和 hrefByParts 的 search 参数。

它不同于 InferRouteSpecSearchValues :后者表示从 URL 解码后得到的完整 页面状态,而不是生成 URL 时接受的 partial 输入。

@example
const orders = routeSpec({
  path: '/orders',
  search: ordersSearch,
})

type OrdersSearchInput = InferRouteSpecSearchInput<typeof orders>

function openOrders(search: OrdersSearchInput) {
  navigate(orders(search))
}

openOrders({
  keyword: 'decurl',
  page: 2,
})
InferRouteSpecSearchInput
,
type
type InferRouteSpecSearchValues<TSpec> = RouteSpecAssociatedTypesOf<TSpec> extends {
    searchValues: infer TValues;
} ? TValues : never

提取 RouteSpec 从 URL 解码 search params 后得到的业务值类型。

这是从 URL 读取数据的结果类型。它会反映 codec 的 optional 和 defaultValue 语义:可选字段可能是 undefined,具有默认值的字段则是必需值。它适合用于 页面状态、组件 props,以及消费 useSearchValues 解码结果的业务函数。

它不同于 InferRouteSpecSearchInput :后者是生成 URL 时接受的 partial 输入,因此字段可选并可能接受 nullish 值。

@example
const orders = routeSpec({
  path: '/orders',
  search: ordersSearch,
})

type OrdersSearchValues = InferRouteSpecSearchValues<typeof orders>

type OrdersTableProps = {
  search: OrdersSearchValues
}

function OrdersPage() {
  const [search] = useSearchValues(orders.search)

  return <OrdersTable search={search} />
}
InferRouteSpecSearchValues
,
const routeSpec: <const TPath extends RouteSpecPath, const TSearch extends RecordCodec | undefined = undefined>(options: RouteSpecOptions<TPath, TSearch>) => RouteSpec<TPath, RouteSpecPathParams<TPath>, TSearch>

定义一个可调用的路由规格。

调用返回由 path params 和 search params 共同生成的 href,同时保留原始 path pattern、search definition 和分组生成 API。

@example
const userDetail = routeSpec({
  path: '/users/:id',
  search: userSearch,
})

userDetail({ id: 1, tab: 'profile' })
userDetail.path
routeSpec
,
} from '@guanriyue/decurl/routeSpec'; const
const searchFields: {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
}
searchFields
=
defineFields<{
    keyword: SingleOptionalFieldCodec<string>;
    page: SingleRequiredFieldCodec<number>;
}>(definition: {
    keyword: SingleOptionalFieldCodec<string>;
    page: SingleRequiredFieldCodec<number>;
}): {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
}
defineFields
({
keyword: SingleOptionalFieldCodec<string>
keyword
:
field<string>(definition: SingleOptionalFieldCodec<string>): SingleOptionalFieldCodec<string> (+7 overloads)
field
({
decode: Decode<string, string>
decode
: (
input: string
input
) =>
input: string
input
||
var undefined
undefined
,
}),
page: SingleRequiredFieldCodec<number>
page
:
field<number>(definition: SingleRequiredFieldCodec<number>): SingleRequiredFieldCodec<number> (+7 overloads)
field
({
decode: Decode<string, number>
decode
: (
input: string
input
) => {
const
const value: number
value
=
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
(
input: string
input
);
return
var Number: NumberConstructor

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

Number
.
NumberConstructor.isSafeInteger(number: unknown): boolean

Returns true if the value passed is a safe integer.

@paramnumber A numeric value.
isSafeInteger
(
const value: number
value
) &&
const value: number
value
>= 1
?
const value: number
value
:
var undefined
undefined
;
},
defaultValue: number
defaultValue
: 1,
}), }); const
const users: RouteSpec<"/users/:groupId", {
    groupId: string | number | boolean;
}, {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
}, never>
users
=
routeSpec<"/users/:groupId", {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
}>(options: RouteSpecOptions<"/users/:groupId", {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
}>): RouteSpec<...>

定义一个可调用的路由规格。

调用返回由 path params 和 search params 共同生成的 href,同时保留原始 path pattern、search definition 和分组生成 API。

@example
const userDetail = routeSpec({
  path: '/users/:id',
  search: userSearch,
})

userDetail({ id: 1, tab: 'profile' })
userDetail.path
routeSpec
({
path: "/users/:groupId"

React Router path pattern。

path
: '/users/:groupId',
search?: {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
} | undefined

用于编码和解码 search params 的字段定义。

search
:
const searchFields: {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
}
searchFields
,
}); type
type UsersParams = {
    groupId: string | number | boolean;
}
UsersParams
=
type InferRouteSpecParams<TSpec> = RouteSpecAssociatedTypesOf<TSpec> extends {
    params: infer TParams;
} ? TParams : never

提取 RouteSpec 生成 pathname 时接受的 path params 类型。

该类型会保留 RouteSpec 对 path params 的显式类型约束,适合用于封装导航函数、 预加载函数或其他需要单独接收 path params 的模块边界。

@example
const userDetail = routeSpec({
  path: '/users/:id',
})

type UserDetailParams = InferRouteSpecParams<typeof userDetail>

function preloadUser(params: UserDetailParams) {
  return queryClient.prefetchQuery({
    queryKey: ['user', params.id],
  })
}
InferRouteSpecParams
<typeof
const users: RouteSpec<"/users/:groupId", {
    groupId: string | number | boolean;
}, {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
}, never>
users
>;
type
type UsersSearchInput = {
    keyword?: string | null | undefined;
    page?: number | null | undefined;
}
UsersSearchInput
=
type InferRouteSpecSearchInput<TSpec> = RouteSpecAssociatedTypesOf<TSpec> extends {
    searchInput: infer TInput;
} ? TInput : never

提取 RouteSpec 生成 href 时接受的 search 输入类型。

这是从业务值写入 URL 的输入类型。字段可以按需传入,并允许使用 nullundefined 表示不写入或移除对应的 search param。它适合用于搜索表单、导航 函数和 hrefByParts 的 search 参数。

它不同于 InferRouteSpecSearchValues :后者表示从 URL 解码后得到的完整 页面状态,而不是生成 URL 时接受的 partial 输入。

@example
const orders = routeSpec({
  path: '/orders',
  search: ordersSearch,
})

type OrdersSearchInput = InferRouteSpecSearchInput<typeof orders>

function openOrders(search: OrdersSearchInput) {
  navigate(orders(search))
}

openOrders({
  keyword: 'decurl',
  page: 2,
})
InferRouteSpecSearchInput
<typeof
const users: RouteSpec<"/users/:groupId", {
    groupId: string | number | boolean;
}, {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
}, never>
users
>;
type
type UsersSearchValues = {
    keyword: string | undefined;
    page: number;
}
UsersSearchValues
=
type InferRouteSpecSearchValues<TSpec> = RouteSpecAssociatedTypesOf<TSpec> extends {
    searchValues: infer TValues;
} ? TValues : never

提取 RouteSpec 从 URL 解码 search params 后得到的业务值类型。

这是从 URL 读取数据的结果类型。它会反映 codec 的 optional 和 defaultValue 语义:可选字段可能是 undefined,具有默认值的字段则是必需值。它适合用于 页面状态、组件 props,以及消费 useSearchValues 解码结果的业务函数。

它不同于 InferRouteSpecSearchInput :后者是生成 URL 时接受的 partial 输入,因此字段可选并可能接受 nullish 值。

@example
const orders = routeSpec({
  path: '/orders',
  search: ordersSearch,
})

type OrdersSearchValues = InferRouteSpecSearchValues<typeof orders>

type OrdersTableProps = {
  search: OrdersSearchValues
}

function OrdersPage() {
  const [search] = useSearchValues(orders.search)

  return <OrdersTable search={search} />
}
InferRouteSpecSearchValues
<typeof
const users: RouteSpec<"/users/:groupId", {
    groupId: string | number | boolean;
}, {
    keyword: WithDefinedFieldName<SingleOptionalFieldCodec<string>>;
    page: WithDefinedFieldName<SingleRequiredFieldCodec<number>>;
}, never>
users
>;

InferRouteSpecParams

Extracts the path params needed to generate a pathname:

type UsersParams = {
  groupId: string | number | boolean
}

InferRouteSpecSearchInput

Extracts the partial search input accepted when generating hrefs:

type UsersSearchInput = {
  keyword?: string | null | undefined
  page?: number | null | undefined
}

It describes the write direction, so every field is optional and nullish input semantics are preserved.

InferRouteSpecSearchValues

Extracts the complete business values after URL decode:

type UsersSearchValues = {
  keyword: string | undefined
  page: number
}

It describes the read direction and reflects the type difference between optional fields and fields with defaultValue.

Boundaries

  • RouteSpec only generates in-app href strings. It does not add React Router basename.
  • RouteSpec does not match pathname or parse path params. Pages should continue using React Router useParams.
  • RouteSpec does not define parent/child route composition and does not replace router config.
  • RouteSpec does not generate full URLs with origin.
  • Hash contract is a TODO and is not described as RouteSpec API yet.
  • Navigation state contract is a TODO and is not described as RouteSpec API yet.