首页
/ Angular Query 类型体系解析:DefinedCreateQueryResult 如何在 TypeScript 中保证 `data` 永远非空

Angular Query 类型体系解析:DefinedCreateQueryResult 如何在 TypeScript 中保证 `data` 永远非空

2026-09-07 13:01:01作者:农烁颖Land

DefinedCreateQueryResult@tanstack/angular-query-experimental(Angular Query,位于本仓库 packages/angular-query-experimental)暴露的核心结果类型别名之一。它描述了当查询一定能返回数据时injectQuery / injectQueries 注入的结果在类型层面会收敛为“data 必定有值”的形态。阅读本文后,你将能理解这个类型的三层内部构成(BaseQueryNarrowingOmitKeyofMapToSignals)、它何时会被自动推导出来、以及它和普通 CreateQueryResult 在模板与组件类型推断上的差异,进而在自己的业务代码中利用类型守卫写出“非空数据”的可靠分支。

本文所讲的类型别名定义在 DefinedCreateQueryResult.md,其真实实现位于 types.ts

DefinedCreateQueryResult 到底是什么

DefinedCreateQueryResult(意即“已定义”的查询结果)是 Angular Query 对查询状态在数据层面必然非空这一运行时事实的静态描述。在 Angular 中,injectQuery 的结果不是一个普通对象,而是一组与组件响应式系统(signals)紧密绑定的字段;当开发者在 options 中提供了“已定义”的初始数据(initialData 为确定值或返回确定值的函数)时,运行时数据从一开始就是存在的,因此类型系统会把结果的 data 推导为 TData 而非 TData | undefined

原始定义如下:

type DefinedCreateQueryResult<
  TData = unknown,
  TError = DefaultError,
  TState = DefinedQueryObserverResult<TData, TError>,
> = BaseQueryNarrowing<TData, TError> &
  MapToSignals<OmitKeyof<TState, keyof BaseQueryNarrowing, 'safely'>>

三个类型参数含义为:

  • TData:查询成功后的数据类型,默认 unknown
  • TError:查询失败时的错误类型,默认 DefaultError(来自 @tanstack/query-core 的统一错误类型);
  • TState:底层 observer 的状态形状,默认收敛为 DefinedQueryObserverResult<TData, TError>,即“数据已定义”的 observer 结果,不需要调用方手工指定。

整个类型由两部分交叉构成:左边的 BaseQueryNarrowing 提供按状态收窄的类型谓词,右边通过 MapToSignals 把剔除重复键后的状态字段全部“信号化”。下面逐层拆解。

第一层:BaseQueryNarrowing——状态收窄的类型守卫

BaseQueryNarrowingtypes.ts 中定义,它暴露了三个类型谓词方法:

export interface BaseQueryNarrowing<TData = unknown, TError = DefaultError> {
  isSuccess: (
    this: CreateBaseQueryResult<TData, TError>,
  ) => this is CreateBaseQueryResult<
    TData,
    TError,
    CreateStatusBasedQueryResult<'success', TData, TError>
  >
  isError: (
    this: CreateBaseQueryResult<TData, TError>,
  ) => this is CreateBaseQueryResult<
    TData,
    TError,
    CreateStatusBasedQueryResult<'error', TData, TError>
  >
  isPending: (
    this: CreateBaseQueryResult<TData, TError>,
  ) => this is CreateBaseQueryResult<
    TData,
    TError,
    CreateStatusBasedQueryResult<'pending', TData, TError>
  >
}

这三个方法的返回类型都是 this is ... 形式的类型谓词(type predicate)。其中 CreateStatusBasedQueryResult<'success' | 'error' | 'pending', ...> 借助 Extract<QueryObserverResult<TData, TError>, { status: TStatus }> 从完整状态联合中筛出对应 status 的分支(见 types.ts)。因此当你写下:

if (query.isSuccess()) {
  // 这里 TypeScript 知道查询已成功,data 可取
}

编辑器即可在 if 分支内自动收窄结果对象的可访问字段集合。

需要特别注意的是:BaseQueryNarrowing 之所以要从状态中单独剥离出来交叉组合,是因为 injectQuery 的结果中这些“状态判断”必须是可调用的方法(Angular 模板中形如 query.isSuccess()),而不是信号;这与后文 MapToSignals 中“函数原样保留、其余包装为信号”的规则相互配合。

第二层:MapToSignals——把状态字段映射为 Angular Signals

DefinedCreateQueryResult 的右侧部分 MapToSignals<OmitKeyof<TState, keyof BaseQueryNarrowing, 'safely'>> 由两个工具类型串联完成:

  1. 先用 OmitKeyof'safely'(安全)模式从 TState 中剔除已被 BaseQueryNarrowing 占用的键(即 isSuccess/isError/isPending),避免键名冲突。OmitKeyof@tanstack/query-core 导出(见 types.ts),其 'strictly' | 'safely' 二参用于决定“严格校验键存在”还是“安全忽略缺失键”。
  2. 再把剩余字段交给 MapToSignals 逐键映射。映射规则定义在 signal-proxy.ts
export type MapToSignals<T> = {
  [K in keyof T]: T[K] extends Function ? T[K] : Signal<T[K]>
}

规则非常直白:字段类型若为函数则原样透传,其余字段全部包装为 Angular 的 Signal<T[K]>。这意味着经过 DefinedCreateQueryResult 后:

  • data 变为 Signal<TData>(读取时写 query.data());
  • error 变为 Signal<TError | null>(或对应联合分支的非空形态);
  • statusisFetchingfetchStatus 等普通属性都变成对应类型的信号;
  • refetch 这类函数方法保持函数形态不变,可直接以 query.refetch() 调用。

与之对应,运行时通过 signalProxysignal-proxy.ts)用 Proxy 将信号内部状态实时暴露给组件,二者在源码上是一一对应的设计。

第三层:默认状态 DefinedQueryObserverResult——为什么 data 必然非空

第三个类型参数 TState 默认是 DefinedQueryObserverResult<TData, TError>,该类型来自 @tanstack/query-core。在 packages/query-core/src/types.ts 中它被定义为两个状态分支的联合:

export type DefinedQueryObserverResult<
  TData = unknown,
  TError = DefaultError,
> =
  | QueryObserverRefetchErrorResult<TData, TError>
  | QueryObserverSuccessResult<TData, TError>

也就是说,Defined... 形态的 observer 结果只会处于两种状态:

  • 成功(success)data: TDataerror: nullisError: false
  • 后台重取失败(refetch error,但已有旧数据)data: TDataerror: TErrorisError: true

观察 query-core 中这两个分支接口的声明即可确认:无论是 QueryObserverSuccessResulttypes.ts)还是 QueryObserverRefetchErrorResulttypes.ts),它们的 data 字段类型都是 TData 而非 TData | undefined。这正是“已定义”的语义来源——只要状态属于 DefinedQueryObserverResult,无论最终落到哪个分支,data 都一定存在。与之对照,普通 QueryObserverResult 还额外包含 pendingloadingloadingErrorplaceholder 等分支,那些分支中 data 可能为 undefined

这也是 DefinedCreateQueryResultCreateQueryResult 的本质差异:CreateQueryResult<TData, TError> 等价于 CreateBaseQueryResult<TData, TError>types.ts),其默认状态为普通 QueryObserverResultdata 允许为 undefined;而 DefinedCreateQueryResult 从类型层面就把“无数据”的分支排除掉了。

什么时候会返回 DefinedCreateQueryResult

类型别名本身不会凭空生效,真正让用户拿到“defined”结果的是两个注入 API 的重载推断逻辑

injectQuery:按 initialData 是否确定切换返回类型

inject-query.tsinjectQuery 提供了两组函数重载:

  • 当参数函数返回 DefinedInitialDataOptions(即 initialData确定的非 undefined 值,或返回非 undefined 值的函数)时,返回类型为 DefinedCreateQueryResult<TData, TError>
  • 当参数函数返回 UndefinedInitialDataOptionsinitialData 缺省或显式为 undefined)时,返回类型退化为 CreateQueryResult<TData, TError>

其中 DefinedInitialDataOptions 定义于 query-options.ts,它通过 NonUndefinedGuard<TQueryFnData>(见 types.tsT extends undefined ? never : T)把“值为 undefined”的初始数据从类型上排除。例如:

class TodoService {
  readonly todosQuery = injectQuery(() => ({
    queryKey: ['todos'],
    queryFn: () => this.http.get<Todo[]>('/todos'),
    initialData: [], // 提供确定初始值
  }))

  // todosQuery 的类型即 DefinedCreateQueryResult<Todo[], DefaultError>
  // 因此 todosQuery.data() 推断为 Todo[],而不是 Todo[] | undefined
}

反过来说,一旦 initialData 被写成 undefined、或干脆省略,重载就会落到 UndefinedInitialDataOptions 分支,此时 data() 的推断会回到可能为空的联合类型。

injectQueries:逐项判断每个 query 是否 defined

批量注入的 inject-queries.ts 同样内置了条件类型 GetDefinedOrUndefinedQueryResult:它检查传入的每个 options 是否带有 initialData 且该值(或 initialData 函数返回结果)在类型上非 undefined——满足则该项推导为 DefinedCreateQueryResult,否则推导为 CreateQueryResult。这样就实现了按配置项粒度决定数组中每一条结果 data 的可空性。

组合使用:让非空类型穿透查询组合

由于 injectQueries 支持传入 queries: [...] 数组,开发者可以把上面两个结论叠加起来使用:先用类型守卫或具名查询配置(例如通过重用的查询选项对象)保证每条查询都携带确定的 initialData,再通过条件类型拿到每条结果的非空 data 信号。该场景下若想精确控制泛型,可在自定义封装函数中使用 OmitKeyof<CreateQueryOptions<...>, 'queryKey' | 'queryFn' | 'initialData', 'safely'>(该用法同样出现在本包的测试封装中,见 inject-query.test.ts),保留“初始数据必须确定”的约束。

类型层面的佐证:类型测试如何锁定行为

DefinedCreateQueryResult 的行为并不是“口头约定”,而是被仓库里的类型级测试(*.test-d.ts,vitest + expectTypeOf)锁定下来的契约。以 inject-queries.test-d.ts 为例:

it('TData should always be defined when initialData is provided as an object', () => {
  const query1 = {
    queryKey: key1,
    queryFn: () => ({ wow: true }),
    initialData: { wow: false },
  }
  const query2 = {
    queryKey: key2,
    queryFn: () => 'Query Data',
    initialData: 'initial data',
  }
  const query3 = {
    queryKey: key3,
    queryFn: () => 'Query Data',
    // 没有 initialData
  }

  const queryResults = injectQueries(() => ({ queries: [query1, query2, query3] }))
  const query1Data = queryResults()[0].data()
  const query2Data = queryResults()[1].data()
  const query3Data = queryResults()[2].data()

  expectTypeOf(query1Data).toEqualTypeOf<{ wow: boolean }>()
  expectTypeOf(query2Data).toEqualTypeOf<string>()
  expectTypeOf(query3Data).toEqualTypeOf<string | undefined>()
})

测试断言了三种情形:

  1. query1 携带对象形态的 initialData,其 data() 被推断为 { wow: boolean }(非空);
  2. query2 携带字符串 initialDatadata() 推断为 string(非空);
  3. query3 省略 initialDatadata() 推断为 string | undefined(可为空)。

这正是 DefinedCreateQueryResultCreateQueryResult 在类型推导层面的差异在真实 API 上的体现。该文件的另一个用例(inject-queries.test-d.ts)进一步验证:当 options 通过 queryOptions() 包装后,initialData 的非空语义依然能正确穿透到最终结果类型。

实战建议:善用“defined”形态精简判空逻辑

基于 DefinedCreateQueryResult 的语义,在实际 Angular 业务中可以形成几条清晰的使用原则:

  • 给查询一个确定的 initialData,换取 data() 的非空类型。对于列表页、分页缓存等“首次渲染就应有展示数据”的场景尤其适用,能够省去大量 data() ?? [] 之类的判空样板。
  • 不要把 initialDataplaceholderData 混淆initialData 会写入缓存并参与缓存生命周期,因此它带来的是“defined”类型收窄;而占位数据不会让 data 在类型上变为非空。两者在 query-options.ts 中分别由不同的 options 类型表达,语义上不应混用。
  • 利用 isSuccess() 等谓词做更细的收窄BaseQueryNarrowing 的三个谓词在 if 分支内不仅告诉你“是否成功”,还会把整个结果对象收窄到对应 status 的状态分支,因此即使面对普通 CreateQueryResult,也能在分支内安全访问 data

与其他结果类型的关系速览

若想进一步横向对比,本仓库的类型别名参考页还提供了相近的相邻类型:

简单概括:CreateQueryResult 是“可能没数据”的查询结果,DefinedCreateQueryResult 是“一定有数据”的查询结果,两者在 injectQuery / injectQueries 的重载与条件类型推断中自动切换,且都由同一套 BaseQueryNarrowing + MapToSignals 的底层机制生成。理解了它,你就掌握了 Angular Query 结果类型从“联合状态收窄”到“信号化”再到“非空化”的完整链路。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388