TS2589 on update() for a relation-heavy model since 3.7.0
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 20/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Khá rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- postgresql, typescript
- Lĩnh vực
- developer-experience
Hướng nghiên cứu
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.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
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.
- Ngôn ngữ chính
- TypeScript
- Star
- 2.9k
- Fork
- 157
- Merge trung bình
- 11 giờ 42 phút
- Pull request đã merge (30 ngày)
- 20
Chuẩn bị môi trường
Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của zenstackhq/zenstack
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
zenstackhq/zenstack#2873 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
runtime
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
zenstackhq/zenstack#2868 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
zenstackhq/zenstack#2694 · 3 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 68/100
zenstackhq/zenstack#2659 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
zenstackhq/zenstack#2542 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của zenstackhq/zenstack
Issue tương tự
-
Mondriaan
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
knaw-huc/textannoviz#709 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Add: YRF Music NepalĐang mởstreams:add
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 62/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
walletbeat/walletbeat#1558 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
hawk-digital-environments/HAWKI#438 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
good first issue
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
OktoLabsAI/okto-pulse#114 ·
Maintainer thường phản hồi trong vòng 1 ngày