8000 Versioning with subpaths (`"zod/v4"`) to enable incremental migration · Issue #4371 · colinhacks/zod · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content
Versioning with subpaths ("zod/v4") to enable incremental migration #4371
Closed
@colinhacks

Description

@colinhacks

This is a writeup of Zod 4's approach to versioning, with the goal of making it easier for users and Zod's ecosystem of associated libraries to migrate to Zod 4.

The general approach:

  • Zod 4 will not initially be published as zod@4.0.0 on npm. Instead it will be exported at a subpath ("zod/v4") alongside zod@3.25.0
  • Despite this, Zod 4 is considered stable and production-ready
  • Zod 3 will continue to be exported from the package root ("zod") as well as a new subpath "zod/v3". It will continue to receive bug fixes & stability improvements.

This approach is analogous to how Golang handles major version changes: https://go.dev/doc/modules/major-version

Sometime later:

  • The package root ("zod") will switch over from exporting Zod 3 to Zod 4
  • At this point zod@4.0.0 will get published to npm
  • The "zod/v4" subpath will remain available forever

How to upgrade?

npm upgrade zod@^3.25.0

Then import Zod 4 from the subpath

import { z } from "zod/v4";

Why?

Zod occupies a unique place in the ecosystem. Many libraries/frameworks in the ecosystem accept user-defined Zod schemas. This means their user-facing API is strongly coupled to Zod and its various classes/interfaces/utilities. For these libraries/frameworks, a breaking change to Zod necessarily causes a breaking change for their users. A Zod 3 ZodType is not assignable to a Zod 4 ZodType.

Why can't libraries just support v3 and v4 simultaneously?

Unfortunately the limitations of peerDependencies (and inconsistencies between package managers) make it extremely difficult to elegantly support two major versions of one library simultaneously.

If I naively published zod@4.0.0 to npm, the vast majority of the libraries in Zod's ecosystem would need to publish a new major version to properly support Zod 4, include some high-profile libraries like the AI SDK. It would trigger a "version bump avalanche" across the ecosystem and generally create a huge amount of frustration and work.

With subpath versioning, we solve this problem. it provides a straightforward way for libraries to support Zod 3 and Zod 4 (including Zod Mini) simultaneously. They can continue defining a single peerDependency on "zod"; no need for more arcane solutions like npm aliases, optional peer dependencies, a "zod-compat" package, or other such hacks.

Libraries will need to bump the minimum version of their "zod" peer dependency to zod@^3.25.0. They can then reference both Zod 3 and Zod 4 in their implementation:

import * as z3 from "zod/v3"
import * as z4 from "zod/v4"

Later, once there's broad support for v4, we'll bump the major version on npm and start exporting Zod 4 from the package root, completing the transition.

As long as libraries are importing exclusively from the associated subpaths (not the root), their implementations will continue to work across the major version bump without code changes.

While it may seem unorthodox (at least for people who don't use Go!), this is the only approach I'm aware of that enables a clean, incremental migration path for both Zod's users and the libraries in the broader ecosystem.


A deeper dive into why peer dependencies don't work in this situation.

Imagine you're a library trying to build a function acceptSchema that accepts a Zod schema. You want to be able to accept Zod 3 or Zod 4 schemas. In this hypothetical, I'm imagine Zod 4 was published as zod@4 on npm, no subpaths. Here are your options:

  1. Install both zod@3 and zod@4 as dependencies simultaneously using npm aliases. This works but you end up including your own copies of both Zod 3 and Zod 4. You have no guarantee that your user's Zod schemas are instances of the same z.ZodType class you're pulling from dependencies (instanceof checks will probably fail).

  2. Use a peer dependency that spans multiple major versions: "zod@>=3.0.0" …but when developing a library you’d still need to pick a version to develop against. Usually you'd install this as a dev dependency. The onus is on you to painstakingly ensure your code works, character-for-character, across both versions. This is impossible in the case of Zod 3 & Zod 4 because a number of very fundamental classes have simplified/different generics.

  3. Optional peer dependencies. i just couldn't find a straight answer about how to reliably determine which peer dep is installed at runtime across all platforms. Many answers online will say "use dynamic imports in a try/catch to check it a package exists". Those folks are assuming you're on the backend because no frontend bundlers have no affordance for this. They'll fail when you try to bundle a dependency that isn't installed. Obviuosly it doesn't matter if you're inside a try/catch during a build step. Also: since we're talking about multiple versions of the same library, you'd need to use npm aliases to differentiate the two versions in your package.json. Versions of npm as recent as v10 cannot handle the combination of peer dependencies + npm aliases.

  4. zod-compat. This extremely hand-wavy solution you see online is "define interfaces for each version that represents some basic functionality". Basically some utility types libraries can use to approximate the real deal. This is error prone, a ton of work, needs to be kept synchronized with the real implementations, and ultimately libraries are developing against a shadow version of your library that probably lacks detail. It also only works for types: if a library depends on any runtime code in Zod it falls apart.

So yeah, subpaths.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      0