Angular Query 类型体系解析:DefinedCreateQueryResult 如何在 TypeScript 中保证 `data` 永远非空
DefinedCreateQueryResult 是 @tanstack/angular-query-experimental(Angular Query,位于本仓库 packages/angular-query-experimental)暴露的核心结果类型别名之一。它描述了当查询一定能返回数据时,injectQuery / injectQueries 注入的结果在类型层面会收敛为“data 必定有值”的形态。阅读本文后,你将能理解这个类型的三层内部构成(BaseQueryNarrowing、OmitKeyof、MapToSignals)、它何时会被自动推导出来、以及它和普通 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——状态收窄的类型守卫
BaseQueryNarrowing 在 types.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'>> 由两个工具类型串联完成:
- 先用
OmitKeyof以'safely'(安全)模式从TState中剔除已被BaseQueryNarrowing占用的键(即isSuccess/isError/isPending),避免键名冲突。OmitKeyof由@tanstack/query-core导出(见 types.ts),其'strictly' | 'safely'二参用于决定“严格校验键存在”还是“安全忽略缺失键”。 - 再把剩余字段交给
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>(或对应联合分支的非空形态);status、isFetching、fetchStatus等普通属性都变成对应类型的信号;refetch这类函数方法保持函数形态不变,可直接以query.refetch()调用。
与之对应,运行时通过 signalProxy(signal-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: TData、error: null、isError: false; - 后台重取失败(refetch error,但已有旧数据):
data: TData、error: TError、isError: true。
观察 query-core 中这两个分支接口的声明即可确认:无论是 QueryObserverSuccessResult(types.ts)还是 QueryObserverRefetchErrorResult(types.ts),它们的 data 字段类型都是 TData 而非 TData | undefined。这正是“已定义”的语义来源——只要状态属于 DefinedQueryObserverResult,无论最终落到哪个分支,data 都一定存在。与之对照,普通 QueryObserverResult 还额外包含 pending、loading、loadingError、placeholder 等分支,那些分支中 data 可能为 undefined。
这也是 DefinedCreateQueryResult 与 CreateQueryResult 的本质差异:CreateQueryResult<TData, TError> 等价于 CreateBaseQueryResult<TData, TError>(types.ts),其默认状态为普通 QueryObserverResult,data 允许为 undefined;而 DefinedCreateQueryResult 从类型层面就把“无数据”的分支排除掉了。
什么时候会返回 DefinedCreateQueryResult
类型别名本身不会凭空生效,真正让用户拿到“defined”结果的是两个注入 API 的重载推断逻辑。
injectQuery:按 initialData 是否确定切换返回类型
在 inject-query.ts 中 injectQuery 提供了两组函数重载:
- 当参数函数返回
DefinedInitialDataOptions(即initialData为确定的非 undefined 值,或返回非 undefined 值的函数)时,返回类型为DefinedCreateQueryResult<TData, TError>; - 当参数函数返回
UndefinedInitialDataOptions(initialData缺省或显式为undefined)时,返回类型退化为CreateQueryResult<TData, TError>。
其中 DefinedInitialDataOptions 定义于 query-options.ts,它通过 NonUndefinedGuard<TQueryFnData>(见 types.ts:T 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>()
})
测试断言了三种情形:
query1携带对象形态的initialData,其data()被推断为{ wow: boolean }(非空);query2携带字符串initialData,data()推断为string(非空);query3省略initialData,data()推断为string | undefined(可为空)。
这正是 DefinedCreateQueryResult 与 CreateQueryResult 在类型推导层面的差异在真实 API 上的体现。该文件的另一个用例(inject-queries.test-d.ts)进一步验证:当 options 通过 queryOptions() 包装后,initialData 的非空语义依然能正确穿透到最终结果类型。
实战建议:善用“defined”形态精简判空逻辑
基于 DefinedCreateQueryResult 的语义,在实际 Angular 业务中可以形成几条清晰的使用原则:
- 给查询一个确定的
initialData,换取data()的非空类型。对于列表页、分页缓存等“首次渲染就应有展示数据”的场景尤其适用,能够省去大量data() ?? []之类的判空样板。 - 不要把
initialData与placeholderData混淆:initialData会写入缓存并参与缓存生命周期,因此它带来的是“defined”类型收窄;而占位数据不会让data在类型上变为非空。两者在 query-options.ts 中分别由不同的 options 类型表达,语义上不应混用。 - 利用
isSuccess()等谓词做更细的收窄:BaseQueryNarrowing的三个谓词在if分支内不仅告诉你“是否成功”,还会把整个结果对象收窄到对应status的状态分支,因此即使面对普通CreateQueryResult,也能在分支内安全访问data。
与其他结果类型的关系速览
若想进一步横向对比,本仓库的类型别名参考页还提供了相近的相邻类型:
- CreateQueryResult:普通查询结果,
data可为空; - CreateBaseQueryResult:承载
BaseQueryNarrowing与信号映射的底层基础类型; - DefinedInitialDataOptions 与 UndefinedInitialDataOptions:决定返回哪种结果形态的输入 options 类型;
- DefinedCreateInfiniteQueryResult 与 CreateInfiniteQueryResult:无限查询对应的非空/普通结果形态。
简单概括:CreateQueryResult 是“可能没数据”的查询结果,DefinedCreateQueryResult 是“一定有数据”的查询结果,两者在 injectQuery / injectQueries 的重载与条件类型推断中自动切换,且都由同一套 BaseQueryNarrowing + MapToSignals 的底层机制生成。理解了它,你就掌握了 Angular Query 结果类型从“联合状态收窄”到“信号化”再到“非空化”的完整链路。
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