You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Always using cts declarations breaks declaration output in packages that use ESM #65
This warning does not cause any resolution issues (unlike "Masquerading as ESM"). Technically, we're tricking TypeScript into thinking our code is CommonJS; when in fact it may be ESM. The ATTW tool is very rigorous and flags this; in practice, this has no real consequences and maximizes compatibility (Zod has relied on the CJS masquerading trick since it's earliest days.)
I believe this may no longer be true. Now that TypeScript has pushed for module writers to use nodenext module type, there is a real consequence for "masquerading as CJS" when operating in an ESM environment. TypeScript assumes the default export of a CJS always contains the entire module.exports, which means it will reference things like z.z.core.$strip in the declaration file that are incorrect. This causes breaking behavior when packages compiled with one module type are used in a TS environment with another (e.g. nodenext vs esnext).
The workaround is to never use the default export from a zshy package in ESM code, but that requires special enforcement/lint rules.
Thanks for filing this @patrickshipe. The README's "no real consequences" line was written before nodenext became the default recommendation, so you're right that it deserves an update. But after digging into this empirically, the conclusion is the opposite of what the Zod PR proposes: the flat-types behavior is what we want to keep, and the cost of switching to per-condition is significantly worse than the cost of leaving things as they are.
Posting the full investigation here so it's the canonical reference for future asks.
The two failure modes
There are two distinct bugs at play, and zshy is forced to pick which one it wants to expose:
The z.z.core.$strip cosmetic emit — TypeScript's type printer chooses a longer qualified path when default-importing a CJS-mode .d.cts under nodenext. Triggers under import z from "zod". Named import (import { z } from "zod") is unaffected.
Structural-comparison breakdown — when "the same" type is loaded from two different file paths, TypeScript treats them as different module identities and structurally compares them. With Zod's recursive $ZodObject/$ZodType chain, this hits TS2589 ("Type instantiation is excessively deep") or, at scale, exhausts the heap entirely.
Bug 1 is a cosmetic emit that breaks consumers' libraries when they default-import. Bug 2 crashes tsc. Bug 2 is a much worse failure mode, and it's the one zshy is actively preventing.
Empirical reproduction
Sandbox: type: module, moduleResolution: nodenext, real zod@4.3.6, real ai@6.0.168, plus a minimal ambient lib-cjs.d.cts simulating any CJS-built library that types its API as strict z.ZodObject<T> (a routine pattern — drizzle-zod, hono validators, tRPC adapters, etc.).
--traceResolution under per-condition shows the split clearly:
Module name 'zod' was successfully resolved to '.../zod/index.d.ts' (from main.ts, ESM)
Module name 'zod' was successfully resolved to '.../zod/index.d.cts' (from lib-cjs.d.cts, CJS)
Module name 'zod/v4' was successfully resolved to '.../zod/v4/index.d.cts' (from ai's d.ts)
Two ZodObject declarations live simultaneously — one rooted at zod/v4/classic/schemas.d.ts, one at zod/v4/classic/schemas.d.cts. Identical content, different file paths, different module identities to TypeScript. When the ESM-typed schema crosses into the CJS-typed function signature, structural comparison of Zod's recursive type chain blows up.
Why flat types work
Under flat types, the top-level types field is the first matching condition for every consumer — your code, your dependencies' .d.ts files, their .d.cts files, transitive types loaded from any module mode. They all converge on a single index.d.cts file. One file, one identity, no structural comparison required.
This is the same root cause as the structural failures across colinhacks/zod#4422, #4903, #4984, #3026, and #3435 — every one of them is a variation of "two file paths providing the same Zod type, structural comparison breaks." Flat types prevents that class of bug by construction.
The reason AI SDK in isolation doesn't crash under per-condition is incidental: their public API is typed as FlexibleSchema = Schema | LazySchema | ZodSchema | StandardSchema, a union loose enough to match via the small flat StandardSchema branch. Most libraries don't type their APIs that defensively.
I tried to find a third way
Four .d.cts-content variants tested, all failing:
Drop export { z } — fixes the bug, breaks import { z } from "zod".
export = z with synthesized namespace — breaks named import (TS2595).
.d.cts re-exporting through .js extensions to share identity with .d.ts chain — fixes named imports, z.z.* returns for default imports.
declare const z value alongside namespace — z.infer<> becomes unfindable.
The z.z.* emit fundamentally comes from TypeScript's type printer choosing a longer qualified path through the synthesized namespace when default-importing a CJS module under nodenext with both default and z exports. There's no .d.cts shape that exposes z as both value and namespace, supports import { z } and import z, and avoids this serialization quirk.
Conclusion
Flat types stay as the default. The trade-off is real, the bigger failure mode is OOM, and we'd rather pay the cosmetic emit cost.
Workaround for downstream library authors: use import * as z from "zod" instead of import z from "zod". This is a one-line source change and the form the Zod docs already use.
@colinhacks thanks for the response here - I think what's missed is that the problem is not cosmetic. When TS outputs declarations using z.z, the types cannot be loaded in consuming code. Meaning all the types become any and the compiler fails.
So you're working in a code base that is some kind of library code where generating TypeScript declaration files is important? Or do you just need declaration files because you're working in a mono repo? Can you confirm that import * solves the problems here? There's a reason why more people are not reporting this, and it's because importing Zod as a default import import z has not been documented as a recommended pattern for many years.
I appreciate you taking the time to answer here @colinhacks !
Correct on library code, and correct that import * solves the problem. One issue is that VS Code, for example, recommends the default import if you type z and it's not already imported. I am not sure about a great solution for this aside from setting up a specific lint rule or something, or messing with the IDE, because this import should be correct, but TypeScript can't reason about it properly when creating declarations due to misunderstanding the module system.
I can see where you're coming from here (wanting to create the least friction in the ecosystem). It'd be nice to default to "correct" behavior (no masquerading) and have an opt-out for those who have issues. I'm also wondering if there is a way the TypeScript team can help us avoid the dual package issues and represent declarations correctly 🤔
Regardless, thank you for all your work with this great library, and thanks for hearing me out 🙇
I just hit a similar issue with what looks like the same root cause. I'm exporting some stuff from a package foo in my monorepo (built using zshy) from fast-check, which (as of v4.6.0; build by rolldown) contains these subpath exports:
The two declaration files are byte-identical. Problematically, each contains a class Stream with a private variable. Since there are two separate definitions and private members are compared nominally by TS, well... you see where this is going.
My when a local consumer of my package (also using zshy) imports from fast-check, they pull in its ESM types. But if they also pull in my fast-check-related utilities from foo, they get the CJS types:
Because of the nominal comparison, I get all manner of TS2769 "Types have separate declarations of a private property" errors when attempting to use both fast-check and foo at once.
The suggested workaround import * as f from 'fast-check' cannot fix this.
For now, my workaround is to force fast-check's ESM types:
...which is pretty gross. Unfortunately, it only works internally to my monorepo; downstream consumers will also have to apply a similar fix in order to use my foo alongside fast-check.
Another workaround may be to just re-export all of fast-check from foo and only consume it that way. 🤷
If I was a troll, I'd file an issue with fast-check demanding they remove their private field.
Since I'm not: are there any better workarounds? Am I SOL here?
Note: I'm not disagreeing with the masquerading strategy; truly, this whole thing seems like a symptom of an unfortunate tradeoff TS has had to make w/r/t private fields.
This may be an argument against the claim that "masquerading as CJS" is not entirely harmless. 😜
Activity
pullfrog commentedon Feb 4, 2026
colinhacks commentedon Apr 27, 2026
Written by pullfrog on behalf of @colinhacks.
Thanks for filing this @patrickshipe. The README's "no real consequences" line was written before
nodenextbecame the default recommendation, so you're right that it deserves an update. But after digging into this empirically, the conclusion is the opposite of what the Zod PR proposes: the flat-types behavior is what we want to keep, and the cost of switching to per-condition is significantly worse than the cost of leaving things as they are.Posting the full investigation here so it's the canonical reference for future asks.
The two failure modes
There are two distinct bugs at play, and zshy is forced to pick which one it wants to expose:
z.z.core.$stripcosmetic emit — TypeScript's type printer chooses a longer qualified path when default-importing a CJS-mode.d.ctsundernodenext. Triggers underimport z from "zod". Named import (import { z } from "zod") is unaffected.$ZodObject/$ZodTypechain, this hits TS2589 ("Type instantiation is excessively deep") or, at scale, exhausts the heap entirely.Bug 1 is a cosmetic emit that breaks consumers' libraries when they default-import. Bug 2 crashes
tsc. Bug 2 is a much worse failure mode, and it's the one zshy is actively preventing.Empirical reproduction
Sandbox:
type: module,moduleResolution: nodenext, realzod@4.3.6, realai@6.0.168, plus a minimal ambientlib-cjs.d.ctssimulating any CJS-built library that types its API as strictz.ZodObject<T>(a routine pattern — drizzle-zod, hono validators, tRPC adapters, etc.).package.json:{ "name": "repro", "type": "module", "private": true }tsconfig.json:{ "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext", "target": "es2022", "strict": true, "skipLibCheck": true, "declaration": true, "outDir": "./dist", "emitDeclarationOnly": true }, "include": ["src"] }src/lib-cjs.d.cts(the realistic CJS-built dependency):src/main.ts:Then patch Zod's
node_modules/zod/package.jsonto per-condition types (the structure proposed by colinhacks/zod#5712):{ ".": { "import": { "types": "./index.d.ts", "default": "./index.js" }, "require": { "types": "./index.d.cts", "default": "./index.cjs" } } }Results
tsctimez.z.*in emitz.z.core.$striptsccrashesz.core.$strip--traceResolutionunder per-condition shows the split clearly:Two
ZodObjectdeclarations live simultaneously — one rooted atzod/v4/classic/schemas.d.ts, one atzod/v4/classic/schemas.d.cts. Identical content, different file paths, different module identities to TypeScript. When the ESM-typed schema crosses into the CJS-typed function signature, structural comparison of Zod's recursive type chain blows up.Why flat types work
Under flat types, the top-level
typesfield is the first matching condition for every consumer — your code, your dependencies'.d.tsfiles, their.d.ctsfiles, transitive types loaded from any module mode. They all converge on a singleindex.d.ctsfile. One file, one identity, no structural comparison required.This is the same root cause as the structural failures across colinhacks/zod#4422, #4903, #4984, #3026, and #3435 — every one of them is a variation of "two file paths providing the same Zod type, structural comparison breaks." Flat types prevents that class of bug by construction.
The reason AI SDK in isolation doesn't crash under per-condition is incidental: their public API is typed as
FlexibleSchema = Schema | LazySchema | ZodSchema | StandardSchema, a union loose enough to match via the small flatStandardSchemabranch. Most libraries don't type their APIs that defensively.I tried to find a third way
Four
.d.cts-content variants tested, all failing:export { z }— fixes the bug, breaksimport { z } from "zod".export = zwith synthesized namespace — breaks named import (TS2595)..d.ctsre-exporting through.jsextensions to share identity with.d.tschain — fixes named imports,z.z.*returns for default imports.declare const zvalue alongside namespace —z.infer<>becomes unfindable.The
z.z.*emit fundamentally comes from TypeScript's type printer choosing a longer qualified path through the synthesized namespace when default-importing a CJS module undernodenextwith bothdefaultandzexports. There's no.d.ctsshape that exposeszas both value and namespace, supportsimport { z }andimport z, and avoids this serialization quirk.Conclusion
Flat types stay as the default. The trade-off is real, the bigger failure mode is OOM, and we'd rather pay the cosmetic emit cost.
Workaround for downstream library authors: use
import * as z from "zod"instead ofimport z from "zod". This is a one-line source change and the form the Zod docs already use.patrickshipe commentedon Apr 28, 2026
@colinhacks thanks for the response here - I think what's missed is that the problem is not cosmetic. When TS outputs declarations using
z.z, the types cannot be loaded in consuming code. Meaning all the types becomeanyand the compiler fails.colinhacks commentedon May 1, 2026
So you're working in a code base that is some kind of library code where generating TypeScript declaration files is important? Or do you just need declaration files because you're working in a mono repo? Can you confirm that
import *solves the problems here? There's a reason why more people are not reporting this, and it's because importing Zod as a default importimport zhas not been documented as a recommended pattern for many years.patrickshipe commentedon May 1, 2026
I appreciate you taking the time to answer here @colinhacks !
Correct on library code, and correct that
import *solves the problem. One issue is that VS Code, for example, recommends the default import if you typezand it's not already imported. I am not sure about a great solution for this aside from setting up a specific lint rule or something, or messing with the IDE, because this import should be correct, but TypeScript can't reason about it properly when creating declarations due to misunderstanding the module system.I can see where you're coming from here (wanting to create the least friction in the ecosystem). It'd be nice to default to "correct" behavior (no masquerading) and have an opt-out for those who have issues. I'm also wondering if there is a way the TypeScript team can help us avoid the dual package issues and represent declarations correctly 🤔
Regardless, thank you for all your work with this great library, and thanks for hearing me out 🙇
boneskull commentedon Jun 19, 2026
I just hit a similar issue with what looks like the same root cause. I'm exporting some stuff from a package
fooin my monorepo (built usingzshy) fromfast-check, which (as of v4.6.0; build byrolldown) contains these subpath exports:The two declaration files are byte-identical. Problematically, each contains a
class Streamwith a private variable. Since there are two separate definitions and private members are compared nominally by TS, well... you see where this is going.My when a local consumer of my package (also using
zshy) imports fromfast-check, they pull in its ESM types. But if they also pull in myfast-check-related utilities fromfoo, they get the CJS types:Because of the nominal comparison, I get all manner of TS2769 "Types have separate declarations of a private property" errors when attempting to use both
fast-checkandfooat once.The suggested workaround
import * as f from 'fast-check'cannot fix this.For now, my workaround is to force
fast-check's ESM types:...which is pretty gross. Unfortunately, it only works internally to my monorepo; downstream consumers will also have to apply a similar fix in order to use my
fooalongsidefast-check.Another workaround may be to just re-export all of
fast-checkfromfooand only consume it that way. 🤷If I was a troll, I'd file an issue with
fast-checkdemanding they remove their private field.Since I'm not: are there any better workarounds? Am I SOL here?
This may be an argument against the claim that "masquerading as CJS" is not entirely harmless. 😜
chore(deps): upgrade fast-check to v4.8.0
chore(deps): upgrade fast-check to v4.8.0
chore(deps): upgrade fast-check to v4.8.0
chore(deps): upgrade fast-check to v4.8.0
chore(deps): upgrade fast-check to v4.8.0
chore(deps): upgrade fast-check to v4.8.0
chore(deps): upgrade fast-check to v4.8.0
chore(deps): upgrade fast-check to v4.8.0
chore(deps): upgrade fast-check to v4.8.0
chore(deps): upgrade fast-check to v4.8.0
chore(deps): upgrade fast-check to v4.8.0
chore(deps): upgrade fast-check to v4.8.0