Skip to content

fix: make ZodType augmentation resilient to competing zod/v4 augmentations - #378

Open
CodeWithAlexander wants to merge 1 commit into
asteasolutions:masterfrom
CodeWithAlexander:fix/zod-v3-compat-module-augmentation
Open

CodeWithAlexander wants to merge 1 commit into
asteasolutions:masterfrom
CodeWithAlexander:fix/zod-v3-compat-module-augmentation

Conversation

@CodeWithAlexander

@CodeWithAlexander CodeWithAlexander commented Apr 8, 2026 •

Copy link
Copy Markdown

Note: this replaces my earlier version of this PR, which moved the augmentation to $ZodType in zod/v4/core. That approach was wrong: it breaks this repo's own build and tests under moduleResolution: "node", because the declare module 'zod/v4/core' target resolves to the ESM .d.ts flavor of zod's declarations while the library's types flow through the .d.cts flavor, splitting module identity. My original root-cause analysis was also incorrect; the real mechanism is below.

Problem

.openapi() disappears (TS2339: Property 'openapi' does not exist on type 'ZodString') when three things share one compilation:

  1. @asteasolutions/zod-to-openapi (or @hono/zod-openapi)
  2. langchain, which pulls in @langchain/langgraph
  3. any package that causes TypeScript to load the zod/v4 subpath (e.g. better-auth, whose .d.ts files contain export * from "zod/v4")

Remove any one and it compiles fine. Minimal repro in the comment below (a one-line synthetic package stands in for ingredient 3).

Root cause

It is not a conflict inside zod itself. @langchain/langgraph also augments zod's ZodType:

// @langchain/langgraph/dist/graph/zod/zod-registry.d.ts
declare module "zod/v4" {
  interface ZodType<out Output = unknown, out Input = unknown, out Internals extends core.$ZodTypeInternals<Output, Input> = core.$ZodTypeInternals<Output, Input>> extends core.$ZodType<Output, Input, Internals> {
    register< ... >(registry: R, meta: SchemaMeta<TOutput, TInput>): ...;
  }
}

'zod' and 'zod/v4' re-export the same ZodType interface, so zod's declaration, langgraph's augmentation and this library's augmentation all merge onto one symbol, but through two different module paths.

TypeScript's declaration-merging rule (TS2428) requires an augmenting declaration that restates a type parameter's constraint or default to restate it identically. Our augmentation restated all of zod's constraints and defaults (Internals extends core.$ZodTypeInternals<...> = ...). Once the interface is merged through a second module path, that identity check fails (the constraint types resolve through different merge chains and are no longer "identical") and the whole merged interface collapses, taking .openapi() with it. With --skipLibCheck false the underlying errors are visible at the augmentation site:

zod-extensions.d.ts: error TS2428: All declarations of 'ZodType' must have identical type parameters.
  Type '$ZodTypeInternals<Output, Input>' is not assignable to type '$ZodTypeInternals<Output, Input>'.
  Two different types with this name exist, but they are unrelated.

Notably, langgraph's own second augmentation (declare module "zod" in plugin.d.ts) survives, because it declares interface ZodType<Output> with no constraints and no defaults. TS2428 only compares constraints and defaults declared on both sides. That leniency is the fix.

Fix

  1. Drop the restated constraint, default and heritage clause from the augmentation: interface ZodType<out Output, out Input>. They were redundant restatements of zod's own declaration and are exactly what TS2428 trips over. Input-typed metadata and the this return type are unchanged. (The type parameter names must still match zod's positionally, since TS2428 compares those by name.)

  2. Declare the augmentation through both 'zod' and 'zod/v4'. Which declaration file each specifier binds to depends on the consumer's moduleResolution mode and TypeScript version (for example, node10 resolves zod/v4 to index.d.cts on TS 5.5 but index.d.ts on TS 5.8), so a single path cannot cover all consumers. The 'zod/v4' block is what keeps .openapi() alive in the collision scenario above.

  3. Force zod/v4 into consuming compilations with export type {} from 'zod/v4';. TypeScript does not load a module that is referenced only by a declare module augmentation (the augmentation silently dies with TS2664 otherwise). This re-export survives declaration emit, emits no runtime code, and adds nothing to the API surface.

  4. Repo-internal: a paths entry pinning zod/v4 to ./node_modules/zod/v4/index.d.ts for this repo's own node10 compilation, mirroring the existing zod/core mapping. TypeScript 5.6 and older have a checker quirk where a second augmentation of the same symbol through a second specifier gets its type parameters appended rather than unified; the mapping keeps the two blocks on separate declaration flavors for the repo's pinned TS 5.5.4. Consumers are unaffected by this file.

This deletes the old comment "This should always perfectly match the zod type definition ... in terms of generics". The reversal is deliberate, and the new comment documents why.

Validation

Repo gate (what CI runs: npm ci && npm run build && npm run lint && npm test, zod 4.0.5, TS 5.5.4 / tsd's TS 5.8.3):

  • build ✅ · jest 48/48 suites, 295 tests (cold cache) ✅ · tsd ✅ · lint ✅

Original repro (zod 4.3.6, langchain 1.3.1, trigger package; both direct-import and @hono/zod-openapi variants):

  • TS 5.9.3 bundler ✅ · TS 5.8.3 bundler ✅ (both were TS2339 before)

Consumer matrix with the packed tarball, covering plain usage, generic z.ZodType-constrained code, and direct import { z } from 'zod/v4':

moduleResolution TS 5.5.4 TS 5.8.3 TS 5.9.3
node (node10) ✅ ✅ ✅
node16 / nodenext n/a ✅ ✅
bundler n/a ✅ ✅

Metadata typing strictness is unchanged relative to the published 8.5.0 (verified on identical probes). No runtime changes; the diff is entirely type-level.

Known limitation: on TS 5.6 and older with node10 resolution, a compilation that also merges ZodType through a second module path can still hit the old checker's merge quirk. Plain consumers on TS 5.5.4/node10 test clean; the collision scenario realistically requires a newer TypeScript anyway (the langchain 1.x ecosystem does).

Environment

  • zod: 4.0.5 (repo) / 4.3.6 (repro)
  • @asteasolutions/zod-to-openapi: 8.5.0 base
  • langchain: 1.3.1
  • TypeScript: 5.5.4 / 5.8.3 / 5.9.3

@CodeWithAlexander

CodeWithAlexander commented Apr 8, 2026 •

Copy link
Copy Markdown
Author

UPDATE

Reproduction

You need three ingredients to trigger this:

  1. @hono/zod-openapi (or @asteasolutions/zod-to-openapi) — the declare module 'zod' augmentation that adds .openapi()
  2. langchain — pulls in @langchain/langgraph which references zod v4's ZodType<Output, Input, Internals>
  3. Any library whose types cause TypeScript to load the "zod/v4" subpath. better-auth is one example (its .d.ts files contain export * from "zod/v4"), but you can also trigger it with a single-line synthetic package.

Minimal repro using a synthetic stand-in for 3:

mkdir repro && cd repro && npm init -y
npm install zod@4.3.6 @asteasolutions/zod-to-openapi@8.5.0 \
  @hono/zod-openapi@1.2.4 langchain@1.3.1 typescript@5.9.3
mkdir -p node_modules/trigger
echo '{ "name": "trigger", "version": "1.0.0", "types": "index.d.ts", "main": "index.js" }' > node_modules/trigger/package.json
echo 'module.exports = {};' > node_modules/trigger/index.js
echo 'export * from "zod/v4";' > node_modules/trigger/index.d.ts

tsconfig.json:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "exclude": ["node_modules"]
}

index.ts:

import { z } from "@hono/zod-openapi";
import { createAgent } from "langchain";
import "trigger";

const test = z.string().openapi({
  param: { name: "id", in: "path", required: true },
});
npx tsc --noEmit
# TS2339: Property 'openapi' does not exist on type 'ZodString'.

Remove any one of the three imports and it compiles fine. The conflict comes from within zod@4.3.6 itself: the zod/v4 subpath exports a ZodType with a different signature than the top-level zod entry point's v3-compat ZodType. When TypeScript merges the augmentation across both, you end up with an unsatisfiable class and .openapi() disappears.

@CodeWithAlexander
CodeWithAlexander force-pushed the fix/zod-v3-compat-module-augmentation branch 2 times, most recently from 3c92f04 to 255ffa2 Compare April 8, 2026 22:24
@CodeWithAlexander CodeWithAlexander changed the title fix: augment core.$ZodType instead of ZodType to prevent zod/v3 compat layer conflict fix: augment core.$ZodType instead of ZodType to prevent subpath merge conflict Apr 8, 2026
…tions

The previous augmentation restated zod's generic constraints, defaults and
heritage clause. TS2428 requires an augmenting declaration's restated
constraints to be identical to the original's, and that identity check fails
once another package (e.g. @langchain/langgraph) merges the same ZodType
interface through the 'zod/v4' module path in the same compilation. The whole
interface merge then collapses and .openapi() disappears.

The augmentation now:
- omits constraints, defaults and the heritage clause, so TS2428's identity
  check never applies (it only compares constraints declared on both sides)
- is declared through both the 'zod' and 'zod/v4' module paths, since which
  declaration flavor each specifier binds to varies by moduleResolution mode
  and TypeScript version
- force-loads 'zod/v4' via an emit-free 'export type {}' re-export, because
  TypeScript does not load modules referenced only by declare-module
  augmentations

The tsconfig paths entry pins the repo's own node10 compilation to the .d.ts
flavor of zod/v4, mirroring the existing zod/core mapping.
@CodeWithAlexander
CodeWithAlexander force-pushed the fix/zod-v3-compat-module-augmentation branch from 255ffa2 to 8535835 Compare August 5, 2026 07:39
@CodeWithAlexander CodeWithAlexander changed the title fix: augment core.$ZodType instead of ZodType to prevent subpath merge conflict fix: make ZodType augmentation resilient to competing zod/v4 augmentations Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant