Skip to content

TypeScript: update/create/upsert data typing causes extreme TSC memory/time on large schemas #2576

@tonxxd

Description

@tonxxd

Summary

On a schema with on the order of 30+ models and rich relations, a normal pattern—HTTP body validated at runtime (e.g. Zod / OpenAPI), then passed into db.<model>.update({ data: … })—can push tsc over a 4 GB Node heap. The same project compiles if the heap is raised (e.g. 8 GB) or if the payload is forced through any before it reaches data.

This is a type-checker cost issue, not a runtime bug.

Environment

  • @zenstackhq/orm: 3.5.x (same general behavior on recent 3.x)
  • TypeScript: 5.9.x
  • tsc full project check/emit including generated zenstack/*.ts and declaration: true (emit makes it worse, but OOM reproduces on full check/emit)

What we see with --generateTrace + @typescript/analyze-trace

Hot spots cluster on Compare types / Determine variance while checking an update call. The stack points at instantiated types from:

  • UpdateInput / NestedUpdateInput (recursive relation update shapes)
  • @zenstackhq/schema helpers such as GetModelField, FieldIsArray, etc.

Relevant ORM sources on dev:

Minimal pattern that hurts (representative)

After OpenAPI/Zod validation, we want to forward the body into ZenStack:

import type { AgentUpdateArgs } from "#zenstack/input"; // generated

// body: inferred from validator — already validated at runtime
await db.agent.update({
  where: { id },
  data: body as AgentUpdateArgs["data"],
});

Observation: Even when casting to AgentUpdateArgs["data"], TypeScript still ends up doing very heavy work relating that to the update method’s expected argument type (including UpdateInputNestedUpdateInputUpdateInput across the schema graph). On a large schema this can OOM at 4 GB.

Same call with the payload passed as any (or a small helper that returns any) completes quickly and stays under 4 GB—so the issue is structural type comparison / instantiation, not application logic.

Workaround (app side)

We use a tiny helper after runtime validation:

function asZenStackMutateData(data: unknown) {
  return data as any;
}

await db.agent.update({
  where: { id },
  data: asZenStackMutateData(body),
});

This restores 4 GB builds for us but drops static checking on data, which is unfortunate given ZenStack’s otherwise strong typing.

Suggestions

  1. Document this as a known TSC limitation for large schemas (similar spirit to Kysely: excessively deep types) and recommend the any / branded escape hatch when data is validated elsewhere.

  2. First-party escape type — e.g. a documented type that is intentionally opaque or widened so assignability to UpdateInput does not force full recursive expansion, or a branded unknown that the client API accepts for data / create / update in an overload.

  3. Bounded recursion depth for nested relation create/update shapes (optional flag or generic depth parameter), collapsing deeper levels to something like Record<string, unknown>—trading precision for check performance.

  4. REST-oriented generated types — optional codegen for scalar-only (or shallow) data types for common CRUD handlers so typical PATCH bodies do not pull full nested relation mutation types.

  5. Investigate SelectSubset + update inference — whether the T extends UpdateArgs<...> + SelectSubset<T, …> pair can be specialized for mutations so validated “plain” objects do not trigger worst-case comparison (may relate to past reports such as #610 on the v3 tracker, though that thread mixed Next/incremental factors).

Repro notes for maintainers

  • Repro scales with model count and relation graph, not a single minimal file.
  • Use tsc --generateTrace traceDir and @typescript/analyze-trace on a private repo or synthetic schema with many models; compare cast to *UpdateArgs['data'] vs data: x as any.
  • TypeScript 5.9 vs 5.8 / 6.x may shift timings.

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