@guanriyue/decurl/routeSpec

@guanriyue/decurl/routeSpec 用于集中定义 React Router path 与 Search Fields,并从业务值生成类型安全的应用内 href。

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

RouteSpec 是 URL contract 和 href builder,不定义 component、loader、action 或路由层级。

routeSpec

调用形式

routeSpec({
  path,
  search?,
})

下面的定义会保留 path pattern 和 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

参数类型必填默认值说明
path`/${string}`-React Router path pattern。
searchRecordCodecundefined用于生成 search params 的 Search Fields。

path 在语义上只包含 pathname pattern,不应包含 search 或 hash。

返回值

routeSpec 返回一个可调用对象:

成员说明
spec(input?)使用扁平输入生成 href。
spec.path原始 React Router path pattern,保留字面量类型。
spec.search原始 Search Fields;未定义时为 undefined
spec.hrefByParts(input)分别传入 path params 与 search input,并生成 href。

生成结果是应用内部 href string:

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

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

如果最终没有生成 search params,返回值不会追加 ?

输入是否必填

RouteSpec 会根据 path params 和 search input 决定 callable input 是否必填:

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

home();
categories();
categories({ lang: 'zh' });
to.users();
to.userDetail({ id: 42 });
  • 没有 path params 和 search 时,不接收 input。
  • 只有可选 path params 或 search 时,input 可以省略。
  • 存在必需 path param 时,input 必填。

Path params

Path params 的推导和 pathname 生成遵循 React Router path pattern:

形式示例输入
必需参数/users/:idid: string | number | boolean
可选参数/:lang?/categorieslang?: string | number | boolean | null | undefined
Splat/files/*'*': string | number | boolean

非 nullish path param 会先通过 String(value) 转换,再交给 React Router generatePath。pathname params 的编码行为遵循 React Router,RouteSpec 不重新实现 path encode:

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

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

search 直接复用 Search Fields。生成 href 时,RouteSpec 只负责把 search input 交给相同的 codec encode 能力,不重新定义 search 编码规则。

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

Search input 是 partial object;具体字段接受什么业务值,以及如何写入 URL,由对应 FieldCodec 决定。

RouteSpec 不负责 search decode。读取 URL 时,页面可以直接复用它保存的 Search Fields:

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

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

Flat input

直接调用 RouteSpec 时,path params 和 search properties 合并为一个扁平对象:

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

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

冲突判断基于 path param 与 Search Fields 的 property key。如果两部分存在同名 property,flat input 会在类型层被禁用,此时必须使用 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 分别接收 path params 和 search input,是无歧义的分组生成 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
部分必填规则说明
params存在必需 path param 时必填只用于生成 pathname。
search只用于生成 search params。

省略可选部分时,运行时按空对象处理。hrefByParts 的 input 对象本身始终需要传入。

配合 React Router

RouteSpec 返回 string,可以直接交给 React Router:

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

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

Router config

Router config 可以复用 RouteSpec 保存的 path pattern:

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

RouteSpec 不保存 parent/child 关系;对象或目录层级也不会产生额外路由语义。

Types

公开类型提取器分别对应 path params、search encode input 和 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

提取生成 pathname 所需的 path params:

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

InferRouteSpecSearchInput

提取生成 href 时接受的 partial search input:

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

它描述写入方向,因此所有字段可选,并保留 nullish 输入语义。

InferRouteSpecSearchValues

提取 URL decode 后的完整业务值:

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

它描述读取方向,会反映 optional Field 与 defaultValue 的类型差异。

能力边界

  • RouteSpec 只生成应用内部 href string,不补充 React Router basename。
  • RouteSpec 不匹配 pathname,也不解析 path params;页面继续使用 React Router useParams
  • RouteSpec 不定义 parent/child route composition,也不替代 router config。
  • RouteSpec 不生成包含 origin 的完整 URL。
  • Hash contract 属于 TODO,当前不作为 RouteSpec API 描述。
  • Navigation state contract 属于 TODO,当前不作为 RouteSpec API 描述。