React Router 的 useLoaderData / useActionData 类型推断 ADR:从盲目类型断言到基于泛式的端到端类型安全
本文解析 React Router 架构决策记录 0003 的核心内容:为什么 Remix v1.6.4 时代的 useLoaderData<MyData>() 泛式本质上是一次"盲目断言"、Date 序列化陷阱如何暴露该设计的缺陷,以及"显式提供隐式输入类型、再推断返回类型"这一决策如何解决 loader/action 与组件之间跨网络的类型对齐问题。读完本文,你将理解该 ADR 的完整论证链、六条解决标准,以及这套设计在 React Router 7 源码中(SerializeFrom、data() 等)的最终落地形态,还能看清它后来如何被 ADR 0012 类型推断 的 typegen 方案取代。
一、ADR 背景:v1.6.4 的"手工对类型"时代
该 ADR(日期 2022-07-11)的目标是:以优秀的开发者体验(DX)实现 useLoaderData 和 useActionData 的端到端类型安全。在 Remix v1.6.4 中,两个 hook 的泛式都要求用户手动指定一个数据类型:
type MyLoaderData = {
/* ... */
};
type MyActionData = {
/* ... */
};
export default function Route() {
const loaderData = useLoaderData<MyLoaderData>();
const actionData = useActionData<MyActionData>();
return <div>{/* ... */}</div>;
}
为了获得"端到端"的类型安全,用户还必须在 loader / action 中保证 json 泛式使用同一个类型:
export const loader: LoaderFunction = () => {
return json<MyLoaderData>({
/* ... */
});
};
export const action: ActionFunction = () => {
return json<MyActionData>({
/* ... */
});
};
也就是说:一份数据形状,要写两遍类型(甚至三遍),且没有任何机制保证两边一致。
二、深挖 v1.6.4 源码:泛式只是把 any 强转成 T
ADR 追溯了 v1.6.4 中 @remix-run/react 的源码,发现 useLoaderData 返回的实际上是一个 any 类型,被隐式强转成泛式传入的任何类型:
export function useLoaderData<T = AppData>(): T {
return useRemixRouteContext().data;
}
interface RemixRouteContextType {
data: AppData; // AppData = any
id: string;
}
export type AppData = any;
化简之后就是:
let data: any;
// 某处,`loader` 被调用并把某个值赋给 `data`
function useLoaderData<T>(): T {
return data; // <-- TypeScript 把这个 `any` 强转为 `T`
}
关键结论:useLoaderData 的返回类型既不基于 data 是怎么被设置的(即 loader 的返回值),也不做任何数据校验,而是盲目地把 data 强转为用户传入的泛式 T。
双重代价:冗余代码 + 序列化陷阱
ADR 指出了当前方案的两个问题:
- DX 差、代码冗余:用户必须手写数据类型的重复声明。数据形状一旦变化,既要改声明的
type/interface,又要改json的实参——而这些类型本可以从json的实参中推断出来。 Date序列化陷阱(footgun):当前方案鼓励用户给json和useLoaderData传同一个类型,但这恰恰是个坑——json可以接受Date这类可 JSON 序列化的类型,而useLoaderData拿到的却是序列化后的类型:
type MyLoaderData = {
birthday: Date;
};
export const loader: LoaderFunction = () => {
return json<MyLoaderData>({ birthday: new Date("February 15, 1992") });
};
export default function Route() {
const { birthday } = useLoaderData<MyLoaderData>();
// ^ useLoaderData 骗过 TypeScript 认为这是 Date,实际上运行时它是一个 string!
}
useActionData 同理。数据经过网络传输必然是 JSON 序列化后的产物,而类型系统对此视而不见——这是一整类"编译通过、运行出错"的隐患。
三、解决方案标准(Solution Criteria)
ADR 给出了六条硬约束,任何候选方案都必须满足:
useLoaderData/useActionData的返回类型应当从loader/action推断出来,而不是盲目类型断言;loader/action自身的返回类型应当是可推断的,这就要求json的返回类型能从其实参推断;- 不允许模块副作用(因此像
makeLoader这样的高阶函数方案被直接排除); json应当允许JSON.stringify允许的一切;json应当只允许JSON.stringify允许的东西;useLoaderData不应返回JSON.parse无法产生的任何东西。
第 4、5、6 条共同刻画了核心不变量:loader 端的可序列化输入约束 与 组件端的"反序列化输出"类型约束必须严格对应,从而消灭 Date 陷阱这一类错误。
四、关键洞察:loader 是 useLoaderData 的"隐式输入"
对"用 TypeScript 泛式推断 hook 返回类型"曾有过犹豫(ADR 引用了社区讨论),因为 TypeScript 泛式天生适合描述/推断输入,而不是用来盲目断言输出。
突破点在于认识到:loader 和 action 其实是 useLoaderData / useActionData 的隐式输入。换句话说,如果保证 loader 和 useLoaderData 运行在同一进程中(不跨网络),我们完全可以写成 useLoaderData(loader),把 loader 变成显式输入:
// 概念上 `loader` 是 `useLoaderData` 的输入
function useLoaderData<Loader extends LoaderFunction>(loader: Loader) {
/*...*/
}
现实中 loader 在浏览器运行时并不存在(它跑在服务端),useLoaderData 需要在编译期获知 loader 的类型。而 loader 与 useLoaderData 由框架统一管理、跨越网络协作,"拿到的数据与自己的 loader 不对应"是极其罕见的边界情况——因此用一个泛式参数把 loader 的类型"显式注入"给 hook 是安全且合理的。ADR 还类比为 Prisma:尽管存在"编译期之后、运行期之前数据库 schema 被修改"这类罕见边界情况,Prisma 依然从运行期可用的 schema 推断类型。
五、决策:显式提供隐式输入的类型,再推断返回值
最终决策:为 useLoaderData 显式提供其隐式输入 loader 的类型,然后由 hook 推断自己的返回类型;action / useActionData 同理:
export const loader = async (args: LoaderArgs) => {
// ...
return json(/*...*/);
};
export default function Route() {
const data = useLoaderData<typeof loader>();
// ...
}
注意这里不再是手写 MyLoaderData 这类独立类型,而是 typeof loader——类型直接锚定在 loader 的真实返回类型上,冗余声明被彻底消除。同时,useLoaderData 推断出的返回类型只包含可序列化的(JSON)类型,从类型层面兑现了"只返回 JSON.parse 能产生的东西"这条标准。
省略泛式时返回 unknown
如果 useLoaderData / useActionData 省略泛式就返回 any,会掩盖潜在的类型错误。ADR 决定改为返回 unknown:
type MyLoaderData = {
/*...*/
};
export default function Route() {
const data = useLoaderData();
// ^? unknown
}
ADR 同时注明:由于这是破坏性变更,把缺省返回类型改为 unknown 被排期到 v2。
弃用"非推断"的泛式写法
直接传一个手写的非推断类型给 useLoaderData,本质是在隐藏一次不安全的类型断言。ADR 决定弃用该写法,引导用户改用显式类型断言——断言清楚地表达了"我在此处做了假设":
export default function Route() {
const dataGeneric = useLoaderData<MyLoaderData>(); // <-- 将被弃用
const dataCast = useLoaderData() as MyLoaderData; // <- 改用这种写法
}
六、决策的后果与硬性约束
ADR 明确列出了该决策带来的行为变化:
- 用户仍可继续提供非推断类型,方式是对
useLoaderData/useActionData的返回值做类型断言; - 用户通过在泛式中写
typeof loader/typeof action来选择性加入类型推断; loader/action的返回类型成为useLoaderData/useActionData推断类型的唯一事实来源(source of truth);- 用户不再需要为跨网络对齐类型而写冗余代码;
useLoaderData/useActionData的返回类型将与json调用中数据序列化后的类型严格对应,消灭一整类错误;- 选择类型推断时,不应再标注
LoaderFunction/ActionFunction——它们会覆盖推断出的更窄的返回类型[^1]。
[^1]: 原 ADR 脚注引用了当时 TypeScript 提案中的 satisfies 运算符:它能约束函数类型的同时保留更窄的推断返回类型,从而让 LoaderFunction / ActionFunction 与类型推断共存。
🚨 最关键的硬性约束:选择类型推断的用户必须从 json 返回 TypedResponse,绝不能返回裸对象:
const loader = () => {
// NO
return { hello: "world" };
// YES
return json({ hello: "world" });
};
只有经过 json(后在 React Router 7 中更名/演进为 data)包装的数据,其返回类型才能被正确推断并施加序列化约束;裸返回的对象类型无法参与这一推断链条。
七、当前仓库中的落地印证:SerializeFrom 与 data()
ADR 描述的是历史设计,但 React Router 7 的源码完整保留并工程化了这套思想。可以对照以下实现逐条验证:
1. hook 的当前签名——"泛式输入 + 序列化后输出"。 在 packages/react-router/lib/hooks.tsx 中:
export function useLoaderData<T = any>(): SerializeFrom<T> {
let state = useDataRouterState(DataRouterStateHook.UseLoaderData);
let routeId = useCurrentRouteId(DataRouterStateHook.UseLoaderData);
return state.loaderData[routeId] as SerializeFrom<T>;
}
export function useActionData<T = any>(): SerializeFrom<T> | undefined {
let state = useDataRouterState(DataRouterStateHook.UseActionData);
let routeId = useCurrentRouteId(DataRouterStateHook.UseLoaderData);
return (state.actionData ? state.actionData[routeId] : undefined) as
| SerializeFrom<T>
| undefined;
}
返回值不再是裸的 T,而是 SerializeFrom<T>——这正是 ADR "推断出的返回类型只包含可序列化 JSON 类型" 的类型学实现。官方文档 useLoaderData 与 useActionData 中的示例仍然使用 useLoaderData<typeof loader>() 这一 ADR 确立的用法。
2. SerializeFrom 的完整定义。 在 packages/react-router/lib/types/route-data.ts 中,该类型先判断函数参数的形态,再决定走"服务端数据"还是"客户端数据"路径:
export type SerializeFrom<T> = T extends (...args: infer Args) => unknown
? Args extends [
| ClientLoaderFunctionArgs
| ClientActionFunctionArgs
| ClientDataFunctionArgs<unknown>,
]
? ClientDataFrom<T> // 客户端函数:数据不过网络,原样保留
: ServerDataFrom<T> // 服务端函数:施加序列化转换
: T;
ServerDataFrom会对其返回值套用Serialize<T>递归映射(同文件的 L16-L46):先识别unstable_SerializesTo品牌类型,已可序列化的类型原样保留,函数一律映射为undefined,并递归处理Promise、Map/Set、数组、元组与对象——这比 ADR 原始设想的"纯 JSON 字符串化"更进一步,与 turbo-stream 传输层支持的容器类型精确对应;ClientDataFrom则跳过序列化映射,因为clientLoader/clientAction的数据不跨网络;- 同文件还定义了
GetLoaderData/GetActionData(L174-L208),处理loader+clientLoader+clientLoader.hydrate+HydrateFallback组合下的数据形态——这恰是后续 ADR 0012 中那张"组合表格"的类型学基础。
3. 文件内的类型级测试。 route-data.ts 末尾内置了一组 Expect<Equal<...>> 类型测试,直接验证了 ADR 关心的行为,例如:
Expect<
Equal<
ServerDataFrom<() => { a: string; b: Date; c: () => boolean; d: unstable_SerializesTo<number> }>,
{ a: string; b: Date; c: undefined; d: number }
>
>
c(函数)被映射为 undefined、d(带序列化品牌)被映射为 number——正是"loader 端只允许可序列化输入、组件端只得到可反序列化输出"这一不变量的可执行证明。
4. json 约束的运行时对应物。 ADR 中"必须走 json"的约束,在当前仓库对应 data() 辅助函数及其 Serializable 入参约束,定义于 packages/react-router/lib/server-runtime/single-fetch.ts:Serializable 是一个递归类型(string | number | boolean | bigint | Date | URL | RegExp | Error | Map | Set | Promise | 数组 | 对象 的递归联合),data(value: Serializable, init?) 的签名把它变成了编译期检查。这同时满足了 ADR 解决标准中"json 允许且只允许 JSON.stringify 允许的东西"两条——函数、Symbol 等不可序列化值在类型层面即被拒绝。
八、结局:被 ADR 0012 取代——从"typeof loader"到 typegen
ADR 头部明确标注了状态:Superseded by #0012(即 decisions/0012-type-inference.md,日期 2024-09-20)。0012 指出 typeof loader 方案虽有实质改进(区别于 useParams<"id"> 那种纯断言泛式),但仍是样板代码且随应用规模放大容易出错,尤其 clientLoader + hydrate + HydrateFallback 的组合下"泛式的正确写法"极其繁琐。
最终方案是放弃用户手写泛式,改为代码生成(typegen):对 routes.ts 返回的每一条路由,把 route 模块的类型生成到 gitignored 的 .react-router/types 目录下(路径镜像,如 app/routes/product.tsx 对应 +types.product.ts),借助 tsconfig.json 的 rootDirs 选项让用户像从兄弟文件一样 import { LoaderArgs, DefaultProps } from "./+types.product",并把 params、loaderData、actionData 作为 props 直接注入 default 组件——useLoaderData 等 hook 因向后兼容保留,但目标是逐步弃用。0012 还系统否决了 defineRoute、defineLoader 系列、Svelte Kit 式"零成本类型安全"(语言服务插件注入)和 TypeScript 插件等替代路线,理由包括 tree-shaking/HMR 兼容性与工具链(typescript-eslint、tsc)的互操作。
从 0003 到 0012 的演进脉络值得注意:0003 确立了"以 loader/action 返回类型为类型事实来源 + 序列化感知"这两个核心原则,0012 只是把"由用户手写 typeof loader 泛式"替换为"由 typegen 自动注入",而 0003 中 SerializeFrom 所依赖的序列化映射逻辑则原样保留在今天的 route-data.ts 中。
九、实践要点小结
- 在本仓库对应的 React Router 7 代码中,推荐写法仍是
useLoaderData<typeof loader>()(见 hooks.tsx 的官方示例注释);若框架模式已启用 typegen,则优先使用生成的Route.LoaderArgs/ props 方案; - loader/action 必须返回
data(...)(v7 中json的继任者)包装的TypedResponse,裸对象返回会绕过序列化感知类型; - 需要给特殊自定义序列化类型声明"序列化后形态"时,使用 unstable_SerializesTo 品牌类型,而不是手写断言;
- 理解 ADR 的论证结构(现状剖析 → 解决标准 → 关键洞察 → 决策 → 后果与约束)是阅读本仓库
decisions/目录下其他 ADR 的通用模板,ADR 模板 可作参考。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00