Skip to content

TS2589 on update() for a relation-heavy model since 3.7.0 #2854

Description

@bgowers

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:

  1. Narrowing the parameter to OrderUncheckedUpdateInput.
  2. Building the payload as a separate annotated local.
  3. data: {...} as OrderUpdateArgs['data'].
  4. 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.

Scale

  • ~140 models in the schema.
  • The model in question has ~35 fields, 2 of them relation fields, plus back-relations from other models.
  • PostgreSQL, @zenstackhq/plugin-policy enabled, access policies on most models.
  • TypeScript 5.x, 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:

  • UpdateInput is XOR<UncheckedUpdateInput, CheckedUpdateInput>, and XOR<T, U> builds (Without<T, U> & U) | (Without<U, T> & T). Without is itself a mapped type over the other side's keys, so each model yields two full key-by-key mapped types rather than one.
  • UpdateRelationInput maps over the model's relation fields to UpdateRelationFieldPayload, which expands to the create / createMany / connect / connectOrCreate / update / upsert set, each carrying a nested input for the related model — which repeats the whole construction, including its own XOR.
  • SelectSubset<T, U> is instantiated with U = CrudArgsType<..., 'update', ...>, and the method's constraint T 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:

  1. Defer the nested relation payloads. UpdateRelationFieldPayload is 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.
  2. 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.
  3. Make the checked/unchecked choice per field rather than per model. The FK-vs-relation decision is really per relation field, but XOR applies 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.
  4. Skip the XOR where it is vacuous. For a model with no relation fields the two halves coincide, so the union is pure overhead.
  5. Apply the same to create. CreateInput is XOR<UncheckedCreateInput, CheckedCreateInput> with the same nested-payload shape, so it should have the same profile even though update is 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:

  1. TS2589 clears, and
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions