TS2589 on update() for a relation-heavy model since 3.7.0
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 新手友好度
- 20/100
- Issue 类型
- 缺陷
- 描述清晰度
- 基本清楚
- 活跃度
- 活跃
- 技术栈
- postgresql, typescript
调研方向
The types are emitted into the generated client, and the payload points at UpdateInput = XOR<UncheckedUpdateInput, CheckedUpdateInput>, UpdateRelationInput/UpdateRelationFieldPayload and SelectSubset<...> in the shipped dist/index.d.mts (and .d.cts), so start by finding the generator that emits them, changed in 3.7.0. A small project does not fail, so reproduce by running tsc (high heap) over a large multi-project program with the policy plugin. Done means TS2589 clears on that program while nested relation-form call sites still compile.
由索引模型根据 Issue 内容生成。
描述
Description
Upgrading from 3.6.4 to 3.9.6 makes tsc fail with TS2589: Type instantiation is excessively deep and possibly infinite on an ordinary update() call against a model with a moderate number of relations. The same call type-checks fine on 3.6.4.
update(id: string, data: OrderUpdateArgs['data']): Promise<Order> {
return this.db.order.update({
where: { id },
data: { ...data, updatedBy: userId },
});
}
order.repository.ts:12:12 - error TS2589: Type instantiation is excessively deep and possibly infinite.
Found 1 error.
Which versions
| Version | Result |
|---|---|
| 3.6.4 | clean |
| 3.7.0 | TS2589 |
| 3.9.6 | TS2589 |
3.7.0 is where the generator began emitting Checked/Unchecked create and update inputs per model, and where UpdateInput became XOR<UncheckedUpdateInput, CheckedUpdateInput>. That timing lines up with the error appearing, though I have not proven the XOR itself is the cause.
The call site cannot work around it
The error is reported on the update() call, and it survives every form I tried:
- Narrowing the parameter to
OrderUncheckedUpdateInput. - Building the payload as a separate annotated local.
data: {...} as OrderUpdateArgs['data'].- Asserting the whole argument:
update({ where, data } as OrderUpdateArgs).
Even with the entire argument asserted, the error stands, which points at the signature rather than the argument expression:
update<T extends CrudArgsType<Schema, Model, 'update', Options, ExtQueryArgs, ExtResult>>(
args: SelectSubset<T, CrudArgsType<Schema, Model, 'update', Options, ExtQueryArgs, ExtResult>, Options>
): ZenStackPromise<CrudReturnType<Schema, Model, 'update', T, Options, ExtResult>>;
SelectSubset<T, CrudArgsType<...>> has to be evaluated whatever the caller passes, so there is no call-site form that avoids it.
Correction: the schema is not the trigger
I originally implied schema size was the cause. Having tried to build a minimum repro, that is wrong, and I would rather say so than send you looking in the wrong place.
Taking our real schema — ~140 models, the failing model with ~35 fields and 2 relation fields, policies on most models — into a standalone project with nothing but @zenstackhq/orm, @zenstackhq/plugin-policy, pg and typescript, generating the client, and compiling the same update() call: it passes, with no errors at all.
I also could not reproduce it with a generated schema at or beyond our scale. All of these compiled clean:
| Variable | Tried up to |
|---|---|
| Models | 142 |
| Scalar fields per model | 35 |
| Relation fields per model | 12 |
Enums (with @map-ed values) |
240 |
Policies traversing relations and comparing to auth() |
61 rules |
| Relation graph shape | uniform, and hub-and-spoke with ~140 back-relations on a hub |
Files each performing an update() and a create() |
60 |
Same client typing as the app (InstanceType<typeof ZenStackClient<typeof schema>>), same experimentalDecorators + emitDecoratorMetadata, same skipLibCheck — none of it reproduces in isolation.
The error only appears inside our application's own compilation, which is large: one tsc program covering the API app and the libraries it imports through path aliases. Consistent with that, the reported line moves around as unrelated code in the file changes (:273, :275, :279 across edits), which is what you would expect if the compiler is hitting a budget rather than failing on one genuinely pathological expression.
So the honest statement of the problem is narrower than my original one: the update-input types are expensive enough that a sufficiently large program hits TypeScript's instantiation ceiling, and the same types are fine in a small one. The environment: PostgreSQL, policy plugin enabled, TypeScript 5.9, strict.
Cost
Even where it succeeds, checking this project needs roughly 4-5 GB: on Node's default heap tsc dies with JavaScript heap out of memory before it can report anything, and only reports the error when given --max-old-space-size=14336. A cold run takes 90-300s. On 3.6.4 the same project checks well inside the default heap.
Where the depth seems to come from
Reading the shipped .d.mts, the expansion looks like it compounds in three places:
UpdateInputisXOR<UncheckedUpdateInput, CheckedUpdateInput>, andXOR<T, U>builds(Without<T, U> & U) | (Without<U, T> & T).Withoutis itself a mapped type over the other side's keys, so each model yields two full key-by-key mapped types rather than one.UpdateRelationInputmaps over the model's relation fields toUpdateRelationFieldPayload, which expands to thecreate/createMany/connect/connectOrCreate/update/upsertset, each carrying a nested input for the related model — which repeats the whole construction, including its ownXOR.SelectSubset<T, U>is instantiated withU = CrudArgsType<..., 'update', ...>, and the method's constraintT extends CrudArgsType<...>references it too, so that type is evaluated to check the call regardless of what the caller passes.
The last point is why this cannot be worked around from user code: narrowing or asserting the argument only changes T, never U.
What would help
Options, roughly in order of how contained they look from the outside:
- Defer the nested relation payloads.
UpdateRelationFieldPayloadis only needed when a caller actually performs a nested write. If it expanded lazily, the common scalar-only update would not pay for the whole relation graph. - Bound the recursion. A depth limit on nested relation inputs, degrading to a looser type past the limit, would keep deep or cyclic schemas finite. Cycles are normal once back-relations exist.
- Make the checked/unchecked choice per field rather than per model. The FK-vs-relation decision is really per relation field, but
XORapplies it to the whole input and doubles the mapped types to do so. Discriminating per field, or via excess-property checking on a single mapped type, would avoid the doubling. - Skip the
XORwhere it is vacuous. For a model with no relation fields the two halves coincide, so the union is pure overhead. - Apply the same to
create.CreateInputisXOR<UncheckedCreateInput, CheckedCreateInput>with the same nested-payload shape, so it should have the same profile even thoughupdateis what fails for us first.
Happy to test a patch against a real schema of this size, or to put together a minimal reproduction if that is more useful than these numbers.
Narrowing it down by experiment
I patched the shipped declarations (dist/index.d.cts and dist/index.d.mts) in place and re-ran a cold tsc against the real schema after each change. Both files have to be patched together: a CommonJS consumer resolves .d.cts, so patching only .d.mts silently changes nothing.
| # | UpdateInput shape |
Nested relation payloads | TS2589 |
|---|---|---|---|
| baseline | XOR<Unchecked, Checked> |
present | fires |
| A | Omit<UncheckedUpdateInput, Without> (XOR dropped) |
partly present — UpdateNonOwnedRelationInput still in the unchecked half |
gone |
| B | XOR<Unchecked, Checked> unchanged |
UpdateRelationInput and UpdateNonOwnedRelationInput replaced with {} |
gone |
Neither ingredient triggers it on its own. In A the recursive relation payloads are still there in the unchecked half and the check passes; in B the XOR is still there and the check passes. It is the combination: XOR expands both operands, and each operand recursively expands relation payloads, so the recursive work happens twice over.
In both experiments the only remaining errors were the expected semantic fallout of the patch (call sites using the relation-object form against an input that no longer offers it), not TS2589.
A caveat on how much these experiments prove. Both of them remove capability rather than deferring it: with the payloads replaced by {}, the relation-object form (someRelation: { connect: … }) stops type-checking altogether, which is exactly why each run left errors at the call sites that use it. So they establish that the XOR and the recursive payloads are jointly necessary to reach the limit, and nothing more. They do not show that any particular fix is sufficient — deleting a type is trivially cheap, whereas deferring one may not be, since XOR still has to compare both operands' members.
On that basis I would treat a candidate fix as working only if both hold:
TS2589clears, and- call sites using the nested relation form still compile.
Every experiment above fails (2) by construction. Option 2 (bounding the recursion) does look the least attractive of the listed options regardless, since it would cost inference on deeply nested writes.
- 主要语言
- TypeScript
- 星标
- 2.9k
- 派生
- 157
- 平均合并
- 4 天 5 小时
- 30 天内合并 PR
- 22
环境准备
这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
zenstackhq/zenstack 的其他 Issue
-
难度 2/5 1-3 小时 新手友好度 68/100
zenstackhq/zenstack#2873 ·
维护者通常 1 天内回复
-
`@default` on a field of a `@map`-ed enum emits the enum name as the column default可能重新可做 @jkugsiya 于 12 天前认领,目前没有进行中的 PR。 未关闭runtime
难度 2/5 1-3 小时 新手友好度 78/100
zenstackhq/zenstack#2868 · 1 条评论 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
zenstackhq/zenstack#2694 · 3 条评论 ·
维护者通常 1 天内回复
-
难度 1/5 1 小时以内 新手友好度 68/100
zenstackhq/zenstack#2659 · 2 条评论 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 65/100
zenstackhq/zenstack#2542 · 1 条评论 ·
维护者通常 1 天内回复
查看 zenstackhq/zenstack 的全部 Issue
相似的 Issue
-
a10y a10y-audit ai-driven-qa claude
难度 2/5 1-3 小时 新手友好度 86/100
linagora/twake-directory-manager#219 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 68/100
维护者通常 1 天内回复
-
type: bug
难度 2/5 1-3 小时 新手友好度 79/100
维护者通常 1 天内回复
-
0️⃣ phase/new
难度 2/5 1-3 小时 新手友好度 82/100
维护者通常 1 天内回复
-
Pulse v7: buildPreferencesFromTelos() bypasses telosSectionOrFile(), renders empty on unified-TELOS installs可能已有人在做 @zephyr91 今天认领。 未关闭
难度 2/5 1-3 小时 新手友好度 75/100
danielmiessler/LifeOS#2393 ·
维护者通常 4 天内回复