TS2589 on update() for a relation-heavy model since 3.7.0
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 20/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- postgresql, typescript
- Domain
- developer-experience
Research direction
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.
Written by the indexing model from the issue text.
Description
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.
- Dominant language
- TypeScript
- Stars
- 2.9k
- Forks
- 157
- Avg merge
- 11h 42m
- Merged PRs (30d)
- 20
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from zenstackhq/zenstack
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
zenstackhq/zenstack#2873 ·
Maintainers usually reply within 1 day
-
runtime
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
zenstackhq/zenstack#2868 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
zenstackhq/zenstack#2694 · 3 comments ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 68/100
zenstackhq/zenstack#2659 · 2 comments ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
zenstackhq/zenstack#2542 · 1 comment ·
Maintainers usually reply within 1 day
All issues in zenstackhq/zenstack
Similar issues
-
area: desktop area: website priority: P2 type: feature
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
appandflow/stim#3411 · 1 comment ·
Maintainers usually reply within 1 day
-
needs triage
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
rjsf-team/react-jsonschema-form#5485 ·
Maintainers usually reply within 2 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
Maintainers usually reply within 1 day
-
community first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Difficulty 1/5 Under an hour Newbie friendliness 65/100
lingdojo/kana-dojo#32090 · 1 comment · 5 reactions ·
Maintainers usually reply within 1 day
-
Friction: Org home and org switcher copy still say repositories and connected agents live in the personal accountPossibly taken A pull request linked to this issue is open or already merged. Openfriction
Difficulty 2/5 1-3 hours Newbie friendliness 80/100
kentcdodds/kody#3265 ·
Maintainers usually reply within 1 day