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 UpdateInput → NestedUpdateInput → UpdateInput … 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
-
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.
-
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.
-
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.
-
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.
-
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.
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 pushtscover a 4 GB Node heap. The same project compiles if the heap is raised (e.g. 8 GB) or if the payload is forced throughanybefore it reachesdata.This is a type-checker cost issue, not a runtime bug.
Environment
@zenstackhq/orm: 3.5.x (same general behavior on recent 3.x)tscfull project check/emit including generatedzenstack/*.tsanddeclaration: true(emit makes it worse, but OOM reproduces on full check/emit)What we see with
--generateTrace+@typescript/analyze-traceHot spots cluster on
Compare types/Determine variancewhile checking anupdatecall. The stack points at instantiated types from:UpdateInput/NestedUpdateInput(recursive relation update shapes)@zenstackhq/schemahelpers such asGetModelField,FieldIsArray, etc.Relevant ORM sources on
dev:UpdateArgs/UpdateInput/NestedUpdateInput— see especiallyNestedUpdateInput, which tiesdata: UpdateInput<...>back into the same recursive structure.updateon the client contract (SelectSubset)Minimal pattern that hurts (representative)
After OpenAPI/Zod validation, we want to forward the body into ZenStack:
Observation: Even when casting to
AgentUpdateArgs["data"], TypeScript still ends up doing very heavy work relating that to theupdatemethod’s expected argument type (includingUpdateInput→NestedUpdateInput→UpdateInput… 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 returnsany) 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:
This restores 4 GB builds for us but drops static checking on
data, which is unfortunate given ZenStack’s otherwise strong typing.Suggestions
Document this as a known TSC limitation for large schemas (similar spirit to Kysely: excessively deep types) and recommend the
any/ branded escape hatch whendatais validated elsewhere.First-party escape type — e.g. a documented type that is intentionally opaque or widened so assignability to
UpdateInputdoes not force full recursive expansion, or a brandedunknownthat the client API accepts fordata/create/updatein an overload.Bounded recursion depth for nested relation
create/updateshapes (optional flag or generic depth parameter), collapsing deeper levels to something likeRecord<string, unknown>—trading precision for check performance.REST-oriented generated types — optional codegen for scalar-only (or shallow)
datatypes for common CRUD handlers so typicalPATCHbodies do not pull full nested relation mutation types.Investigate
SelectSubset+updateinference — whether theT 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
tsc --generateTrace traceDirand@typescript/analyze-traceon a private repo or synthetic schema with many models; compare cast to*UpdateArgs['data']vsdata: x as any.