Version
@zenstackhq/orm, @zenstackhq/client-helpers, @zenstackhq/tanstack-query — all 3.8.3 (current latest).
Summary
Any browser bundle that uses the TanStack Query client ends up containing the entire ORM runtime, including zod, even though the client never executes it. In a bundle of the generated hooks for our schema this is ~421 KB minified (~64% of the bundle), of which zod alone is ~320 KB.
There are two independent causes, and the first looks like a simple oversight.
Cause 1 — a type-only import written as a value import
packages/clients/client-helpers/src/types.ts:1
import { ExtQueryArgsMarker, ExtResultMarker, type QueryOptions } from '@zenstackhq/orm';
ExtQueryArgsMarker and ExtResultMarker are used only in type positions in that file:
export type InferExtQueryArgs<T> = T extends { [ExtQueryArgsMarker]?: infer E } ? ... : {};
export type InferExtResult<T> = T extends { [ExtResultMarker]?: infer E } ? ... : {};
Because the bindings are erased but the specifier is preserved, the emitted output contains a bare side-effect import — packages/clients/client-helpers/dist/index.mjs:3:
import "@zenstackhq/orm";
That is the one import form a bundler cannot drop, so the whole ORM graph is retained. Note QueryOptions on the same line is already marked type, and the sibling file client-helpers/src/transaction.ts imports the same module with a correct import type { ... } — which is why this reads as an oversight rather than a deliberate choice.
Fix:
import type { ExtQueryArgsMarker, ExtResultMarker, QueryOptions } from '@zenstackhq/orm';
Cause 2 — a runtime constant only reachable through the barrel
packages/clients/tanstack-query/src/common/transaction.ts:5
import { CoreReadOperations } from '@zenstackhq/orm';
This one is a genuine runtime value, used at a single site:
if (CoreReadOperations.includes(op.op)) { ... }
It is a 7-element string array (findMany, findUnique, findFirst, count, aggregate, groupBy, exists) defined in packages/orm/src/client/crud/operations/base.ts, so importing it pulls the full barrel. Fixing Cause 1 alone therefore still leaves the ORM in any bundle that includes transaction.ts.
Possible fixes, in rough order of preference:
- Expose the operation-name constants from a leaf entry point (e.g. a new
@zenstackhq/orm/constants) and import them from there. packages/orm/src/common-types.ts is already exactly such a leaf — pure types plus small sentinel classes, no ORM imports — so re-exporting from there would also work with a smaller diff.
- Additionally declare
"sideEffects": false on the client-facing packages. Currently no @zenstackhq/* package declares sideEffects, which prevents bundlers from eliding side-effect-only imports on their own.
Measurement
Synthetic harness over the client-reachable graph (not a full framework build): esbuild, bundle, minify, format=esm, platform=browser, with react, react-dom, @tanstack/react-query and pg marked external. Entry point is a generated hooks tree (55 modules) built on useModelQueries.
| bundle |
size |
| as published |
662.1 KB |
with the exact specifier @zenstackhq/orm aliased to a stub exporting only CoreReadOperations |
240.6 KB |
| difference |
−421.4 KB (−63.7%) |
Top contributors in the unaliased bundle: zod 319.7 KB, schema 113.7 KB, @zenstackhq/orm 48.0 KB.
The aliased build was checked for correctness, not just size: all seven CoreReadOperations members are present, and @zenstackhq/orm/common-types (DbNull / JsonNull / AnyNull, which are real runtime values used by client-helpers/fetch.ts) still resolves and is still bundled. Only the exact specifier can be aliased — aliasing the subpaths too would break those values.
Why an alias is not a good workaround downstream
Aliasing the bare specifier in application config works, but it has to be scoped to the client only (the same package is used for real on the server), and it needs re-verification on every @zenstackhq/* upgrade, since a newly added named import from the barrel would silently become undefined at runtime. Fixing it in the packages removes that burden for everyone.
Offer
Happy to contribute a PR for both causes if you agree with the approach — just let me know which shape you'd prefer for Cause 2 (new constants leaf vs. re-export from common-types), since that's an entry-point decision I'd rather not guess at.
Version
@zenstackhq/orm,@zenstackhq/client-helpers,@zenstackhq/tanstack-query— all 3.8.3 (current latest).Summary
Any browser bundle that uses the TanStack Query client ends up containing the entire ORM runtime, including zod, even though the client never executes it. In a bundle of the generated hooks for our schema this is ~421 KB minified (~64% of the bundle), of which zod alone is ~320 KB.
There are two independent causes, and the first looks like a simple oversight.
Cause 1 — a type-only import written as a value import
packages/clients/client-helpers/src/types.ts:1ExtQueryArgsMarkerandExtResultMarkerare used only in type positions in that file:Because the bindings are erased but the specifier is preserved, the emitted output contains a bare side-effect import —
packages/clients/client-helpers/dist/index.mjs:3:That is the one import form a bundler cannot drop, so the whole ORM graph is retained. Note
QueryOptionson the same line is already markedtype, and the sibling fileclient-helpers/src/transaction.tsimports the same module with a correctimport type { ... }— which is why this reads as an oversight rather than a deliberate choice.Fix:
Cause 2 — a runtime constant only reachable through the barrel
packages/clients/tanstack-query/src/common/transaction.ts:5This one is a genuine runtime value, used at a single site:
It is a 7-element string array (
findMany,findUnique,findFirst,count,aggregate,groupBy,exists) defined inpackages/orm/src/client/crud/operations/base.ts, so importing it pulls the full barrel. Fixing Cause 1 alone therefore still leaves the ORM in any bundle that includestransaction.ts.Possible fixes, in rough order of preference:
@zenstackhq/orm/constants) and import them from there.packages/orm/src/common-types.tsis already exactly such a leaf — pure types plus small sentinel classes, no ORM imports — so re-exporting from there would also work with a smaller diff."sideEffects": falseon the client-facing packages. Currently no@zenstackhq/*package declaressideEffects, which prevents bundlers from eliding side-effect-only imports on their own.Measurement
Synthetic harness over the client-reachable graph (not a full framework build): esbuild,
bundle,minify,format=esm,platform=browser, withreact,react-dom,@tanstack/react-queryandpgmarked external. Entry point is a generated hooks tree (55 modules) built onuseModelQueries.@zenstackhq/ormaliased to a stub exporting onlyCoreReadOperationsTop contributors in the unaliased bundle:
zod319.7 KB, schema 113.7 KB,@zenstackhq/orm48.0 KB.The aliased build was checked for correctness, not just size: all seven
CoreReadOperationsmembers are present, and@zenstackhq/orm/common-types(DbNull/JsonNull/AnyNull, which are real runtime values used byclient-helpers/fetch.ts) still resolves and is still bundled. Only the exact specifier can be aliased — aliasing the subpaths too would break those values.Why an alias is not a good workaround downstream
Aliasing the bare specifier in application config works, but it has to be scoped to the client only (the same package is used for real on the server), and it needs re-verification on every
@zenstackhq/*upgrade, since a newly added named import from the barrel would silently becomeundefinedat runtime. Fixing it in the packages removes that burden for everyone.Offer
Happy to contribute a PR for both causes if you agree with the approach — just let me know which shape you'd prefer for Cause 2 (new
constantsleaf vs. re-export fromcommon-types), since that's an entry-point decision I'd rather not guess at.