TanStack Query Angular 类型收窄实战:深入解析 BaseMutationNarrowing 接口
TanStack Query 的 Angular 版(angular-query-experimental)为 injectMutation 返回的 mutation 结果提供了一套基于类型谓词(type predicate)+ Angular Signal 的状态收窄机制。本文以 BaseMutationNarrowing 接口 为核心,梳理它的四个状态守卫属性(isSuccess/isError/isPending/isIdle)、泛型设计以及在模板与 TypeScript 中的收窄效果。读完本文,你将理解为什么官方建议用 mutation.isSuccess() 而不是 mutation.status() === 'success',并掌握如何写出既在模板中响应式、又在类型上安全可推导的 Angular mutation 代码。
BaseMutationNarrowing 是 mutation 结果类型的"收窄契约"层,源码定义于 types.ts:184,最终通过 CreateMutationResult 组合进
injectMutation的返回值类型中。
一、为什么需要 BaseMutationNarrowing
Angular 版本的 mutation 返回值并非普通对象,而是由 signalProxy(见 signal-proxy.ts)包装的一层 Proxy:所有非函数字段被映射成 computed 信号,函数字段则原样透传(见 signal-proxy.ts:19-45)。对应的类型映射关系定义在 signal-proxy.ts:4-6:
export type MapToSignals<T> = {
[K in keyof T]: T[K] extends Function ? T[K] : Signal<T[K]>
}
也就是说,mutation.data 的类型是 Signal<TData | undefined>,mutation.error 是 Signal<TError | null>,而 mutation.isSuccess、mutation.mutate 等函数成员不会被包成信号。这里产生了一个关键问题:data、error、variables 在不同状态下可取值的区间完全不同,而普通的 Signal 类型无法表达这种随状态变化的关系。
BaseMutationNarrowing 正是为解决这个问题而设计的一层接口——它为结果对象补充了四个既是函数又是 Signal 的收窄谓词(类型别名为 SignalFunction,见 types.ts:182),当你在代码或模板里调用并"成立"时,TypeScript 可以把整个 mutation 结果收窄到对应状态子集,从而让 data、error、variables 的类型同步精确化。
二、泛型参数与默认值
BaseMutationNarrowing 的声明(types.ts:184-258)带四个泛型参数,全部有默认值:
| 泛型参数 | 默认值 | 含义 |
|---|---|---|
TData |
unknown |
mutation 成功后 data 的数据类型 |
TError |
DefaultError |
mutation 失败时 error 的类型(query-core 的 DefaultError,通常为 Error) |
TVariables |
unknown |
调用 mutate 时传入的变量类型 |
TOnMutateResult |
unknown |
乐观更新中 onMutate 的返回类型(供 onError/onSettled 的 context 使用) |
注意一个细节:虽然 CreateMutationOptions(types.ts:126-134)中 TVariables 的默认值是 void(表示"不需要变量"),但在结果类型这条线上 TVariables 默认是 unknown。实际使用 injectMutation 时这些泛型几乎都由 mutationFn 等配置自动推断,无需手写。
在它内部还依赖两个非导出的辅助类型(位于同一文件):
CreateBaseMutationResult(types.ts:154-169):用Override把核心的MutationObserverResult中的mutate替换为返回void的CreateMutateFunction,并额外叠加mutateAsync;CreateStatusBasedMutationResult(types.ts:171-180):通过Extract<CreateBaseMutationResult, { status: TStatus }>从结果联合类型中取出某一具体status的分支。
三、四个状态守卫属性详解
该接口声明了四个属性,源码分别位于 types.ts 的 190、207、224、241 行。它们都被 SignalFunction<T> 包裹——即"普通谓词函数 ∩ 布尔 Signal"的交叉类型,因此既能像信号一样在模板中直接 mutation.isPending() 读取,又能作为 this is ... 谓词参与类型收窄。
isSuccess —— 成功状态守卫
源码见 types.ts:190-206:
isSuccess: SignalFunction<
(
this: CreateMutationResult<TData, TError, TVariables, TOnMutateResult>,
) => this is CreateMutationResult<
TData,
TError,
TVariables,
TOnMutateResult,
CreateStatusBasedMutationResult<
'success',
TData,
TError,
TVariables,
TOnMutateResult
>
>
>
成立后,this 被收窄为 status: 'success' 的 mutation 分支:此时 data 一定存在、error 为 null、variables 为最后一次 mutate 传入的变量。官方类型测试(inject-mutation.test-d.ts:16-24)验证了这一点:
it('data should be defined when mutation is success', () => {
const mutation = injectMutation(() => ({
mutationFn: () => sleep(0).then(() => 'string'),
}))
if (mutation.isSuccess()) {
expectTypeOf(mutation.data).toEqualTypeOf<Signal<string>>()
}
})
在同一分支内 error 也会被收窄为 Signal<null>(inject-mutation.test-d.ts:26-34)。
isError —— 错误状态守卫
源码见 types.ts:207-223,成立后被收窄为 status: 'error' 分支。此时 error 必然是真实的错误对象而非 null,data 则为 undefined:
it('error should be defined when mutation is error', () => {
const mutation = injectMutation(() => ({
mutationFn: () => sleep(0).then(() => 'string'),
}))
if (mutation.isError()) {
expectTypeOf(mutation.error).toEqualTypeOf<Signal<Error>>()
}
})
见 inject-mutation.test-d.ts:46-54。
isPending —— 进行中守卫
源码见 types.ts:224-240,对应 status: 'pending'。mutation 在 mutate 被调用后、请求完成前处于该状态。此时 data 收窄为 undefined(inject-mutation.test-d.ts:36-44):
if (mutation.isPending()) {
expectTypeOf(mutation.data).toEqualTypeOf<Signal<undefined>>()
}
运行时层面,Angular 实现会在此状态下通过 pendingTasks.add() 登记一个 Pending Task,并在状态离开 pending 时清理,用于确保变更检测 / 可中断渲染的稳定性(见 inject-mutation.ts:141-150)。
isIdle —— 初始待命守卫
源码见 types.ts:241-257,对应 status: 'idle'——这是 mutation 尚未执行任何操作时的初始状态,也是与 query 侧最明显的差异点:查询结果默认处于 pending,而 mutation 结果默认处于 idle。在此状态下 variables 是 undefined,因为还没有调用过 mutate(inject-mutation.test-d.ts:56-73):
if (mutation.isIdle()) {
expectTypeOf(mutation.variables).toEqualTypeOf<Signal<undefined>>()
}
if (mutation.isPending()) {
expectTypeOf(mutation.variables).toEqualTypeOf<Signal<string>>()
}
四、与其他类型的组合关系
BaseMutationNarrowing 不是孤立存在的,它被 CreateMutationResult 正式"组装"为 injectMutation 的返回值类型(types.ts:260-273):
export type CreateMutationResult<
TData = unknown,
TError = DefaultError,
TVariables = unknown,
TOnMutateResult = unknown,
TState = CreateStatusBasedMutationResult<
CreateBaseMutationResult['status'],
TData,
TError,
TVariables,
TOnMutateResult
>,
> = BaseMutationNarrowing<TData, TError, TVariables, TOnMutateResult> &
MapToSignals<OmitKeyof<TState, keyof BaseMutationNarrowing, 'safely'>>
结构拆解如下:
- 用
BaseMutationNarrowing提供 4 个守卫谓词; - 用
MapToSignals把剩余的状态字段(data、error、status、variables、submittedAt等)映射成Signal; OmitKeyof<..., 'safely'>保证两个交叉部分不发生字段冲突。
injectMutation 的实现最终把 signalProxy(resultSignal) 断言为 CreateMutationResult 返回(inject-mutation.ts:193-198)。与 query 侧对比,BaseQueryNarrowing(types.ts:51)只有 isError/isPending/isSuccess 三个守卫、没有 isIdle,这正是查询永远不存在"空闲"语义的体现——从源码结构看,这份接口分工忠实反映了两种数据请求模型的差异。
五、模板与类型中的正确用法
模板:当作布尔信号使用
在 Angular 控制流中,四个守卫既是布尔信号又能在 @if 中做分支渲染。官方 mutations 指南(mutations.md)的完整示例:
@Component({
template: `
<div>
@if (mutation.isPending()) {
<span>Adding todo...</span>
} @else if (mutation.isError()) {
<div>An error occurred: {{ mutation.error()?.message }}</div>
} @else if (mutation.isSuccess()) {
<div>Todo added!</div>
}
<button (click)="mutation.mutate(1)">Create Todo</button>
</div>
`,
})
export class TodosComponent {
todoService = inject(TodoService)
mutation = injectMutation(() => ({
mutationFn: (todoId: number) =>
lastValueFrom(this.todoService.create(todoId)),
}))
}
对应的运行时行为已被测试覆盖(inject-mutation.test.ts):组件初始状态四个布尔值应为 isIdle: true、其余为 false;触发 mutate 后翻转为 isPending: true;成功 / 失败时分别翻转为 isSuccess: true / isError: true(见该文件 43-46、108-111、142-145 行附近的断言)。
类型层面:收窄信号字段的类型
在 TS 逻辑中调用守卫后,分支内所有相关 Signal 字段会被同步收窄。上文测试已覆盖:isSuccess() 内 data 为 Signal<string>、error 为 Signal<null>;isPending() 内 data 为 Signal<undefined>;isIdle() 内 variables 为 Signal<undefined>;未收窄时 data 则是 Signal<string | undefined>。
为什么必须用守卫而不是比较 status
从源码和官方说明(typescript.md)都可以确认一个重要结论:TypeScript 目前不支持对对象方法做可辨识联合收窄,即 mutation.status() === 'success' 不会让 mutation.data 的类型变精确;而基于信号字段 data() 直接比较也无法收窄(信号字段是 Signal<T> 对象,不是布尔值)。因此应始终优先使用 isSuccess() 这类布尔状态信号来驱动逻辑分支,例如:
// 推荐:类型可收窄
if (mutation.isSuccess()) {
const value: string = mutation.data() // ✓ data 收窄为 string
}
// 不推荐:无法触发收窄
if (mutation.status() === 'success') {
// mutation.data() 仍是 string | undefined
}
对 query 场景同理(query.isSuccess() 优于 query.status() === 'success'),官方在 typescript.md 的状态映射表中将 success/pending/error 等状态统一对应到各自的 isXxx() 信号,测试与测试框架(如 testing.md 中使用 mutation.isSuccess() 断言)也都遵循这一约定。
六、与 query-core 的底层映射
最后从实现上还原守卫的运行时来源:injectMutation 内部创建 @tanstack/query-core 的 MutationObserver(inject-mutation.ts:80-83),并在订阅回调中把 state.isPending / state.isError 转写为信号结果(inject-mutation.ts:139-160)。四个守卫的 true/false 本质就是核心层 mutation 状态机(idle → pending → success | error)在 Angular 信号层上的投影:isIdle 对应初始 idle,isPending 对应请求进行中,isSuccess/isError 对应两种终态。理解这层映射后,BaseMutationNarrowing 的设计动机就非常清晰——它让 Angular 开发者既能获得响应式渲染的便利,又能让 TypeScript 编译器在每一个状态分支里都给出最精确的信号类型。
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 StartedRust0627
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