Migrate to zod v4 #148

Closed
opened 2026-09-15 18:22:47 +02:00 by lz · 1 comment
Owner

Prerequisite for MCP support. @modelcontextprotocol/server v2.0.0 depends on zod ^4.2.0; Nexus is on ^3.24.1. Either the two coexist cleanly under pnpm or Nexus moves. Moving is the honest answer — a duplicated validation library in one process is a trap for the next person, who will import the wrong z.

Do this first, before any MCP work. A major-version bump discovered mid-implementation is the kind of thing that quietly doubles a task.

Scope is much smaller than the file count suggests

37 files import zod, but the API surface in use is narrow:

79 z.string   40 z.object   12 z.number   8 z.boolean
 7 z.enum      4 z.array     2 z.union    2 z.coerce   1 z.custom   1 z.infer

v4 keeps every one of those.

Measured: zero occurrences of every classic v3→v4 breaking pattern.

Pattern Occurrences
invalid_type_error 0
required_error 0
errorMap 0
.strict( 0
.passthrough( 0
z.record( 0
.email( / .url( 0
.default( 0
.nativeEnum( 0

So this is expected to be close to a version bump plus a typecheck. It is filed as its own issue anyway, because "expected to be small" and "is small" differ, and it must not be discovered inside a feature branch.

Work

  • Bump zod in nexus/package.json; add @modelcontextprotocol/core / @modelcontextprotocol/server only in the MCP issue, not here.
  • pnpm typecheck + pnpm test green.
  • Check the 33 message: occurrences — most are not zod at all, but a { message } inside a .refine() should become { error } where v4 prefers it.
  • Confirm z.coerce behaviour is unchanged for the two call sites that use it; v4 tightened coercion.
  • config.ts is the highest-risk file — it is Zod-validated environment config, and a behaviour change there fails at boot rather than at typecheck.

Done when

pnpm typecheck, pnpm test and pnpm lint are green on zod 4, and a docker compose up -d boots without a config validation error.

**Prerequisite for MCP support.** `@modelcontextprotocol/server` v2.0.0 depends on `zod ^4.2.0`; Nexus is on `^3.24.1`. Either the two coexist cleanly under pnpm or Nexus moves. Moving is the honest answer — a duplicated validation library in one process is a trap for the next person, who will import the wrong `z`. Do this **first**, before any MCP work. A major-version bump discovered mid-implementation is the kind of thing that quietly doubles a task. ## Scope is much smaller than the file count suggests 37 files import zod, but the API surface in use is narrow: ``` 79 z.string 40 z.object 12 z.number 8 z.boolean 7 z.enum 4 z.array 2 z.union 2 z.coerce 1 z.custom 1 z.infer ``` v4 keeps every one of those. **Measured: zero occurrences of every classic v3→v4 breaking pattern.** | Pattern | Occurrences | |---|---| | `invalid_type_error` | 0 | | `required_error` | 0 | | `errorMap` | 0 | | `.strict(` | 0 | | `.passthrough(` | 0 | | `z.record(` | 0 | | `.email(` / `.url(` | 0 | | `.default(` | 0 | | `.nativeEnum(` | 0 | So this is expected to be close to a version bump plus a typecheck. It is filed as its own issue anyway, because "expected to be small" and "is small" differ, and it must not be discovered inside a feature branch. ## Work - Bump `zod` in `nexus/package.json`; add `@modelcontextprotocol/core` / `@modelcontextprotocol/server` only in the MCP issue, not here. - `pnpm typecheck` + `pnpm test` green. - Check the 33 `message:` occurrences — most are not zod at all, but a `{ message }` inside a `.refine()` should become `{ error }` where v4 prefers it. - Confirm `z.coerce` behaviour is unchanged for the two call sites that use it; v4 tightened coercion. - `config.ts` is the highest-risk file — it is Zod-validated environment config, and a behaviour change there fails at boot rather than at typecheck. ## Done when `pnpm typecheck`, `pnpm test` and `pnpm lint` are green on zod 4, and a `docker compose up -d` boots without a config validation error.
lz added this to the MCP support (#140) milestone 2026-09-15 18:22:47 +02:00
Author
Owner

Correction: the "zero breaking patterns" table in the issue body is wrong

The table above is not a measurement. Seven of its ten rows come from a command that errored out and produced no output, which wc -l then counted as zero.

$ grep -rP "\.default(" src/ --include=*.ts
ugrep: error: error at position 14
(?m)\.default(
              \___mismatched ( )

An unescaped ( is an unmatched group in PCRE. Every pattern I wrote containing (.strict(, .passthrough(, z.record(, .email(, .url(, .default(, .nativeEnum( — failed the same way, and stderr was suppressed with 2>/dev/null. Only the three patterns with no parentheses (invalid_type_error, required_error, errorMap) were genuinely searched, and those three really are zero.

Corrected, with fixed-string matching:

Pattern Issue body claimed Actual
.default( 0 22
.url( 0 7
invalid_type_error 0 0
required_error 0 0
errorMap 0 0
.strict( 0 0
.passthrough( 0 0
z.record( 0 0
.email( 0 0
.nativeEnum( 0 0

.default() is the row that mattered: v4 changed it from "parse the default through the schema" to "return it directly", and nine of the 22 are in config.ts — including PORT: z.coerce.number().int().positive().default(3001), a coerce stacked on a default, which is precisely the shape the semantics change is about. They turned out to be safe because every default is already an output-type value, but that is a conclusion from checking, not from counting.

PR #163 found four genuine behaviour changes the bogus table implied could not exist: z.url() trimming and accepting unicode whitespace v3 rejected (both reachable at WORKER_CALLBACK_URL and connections.base_url), .int() requiring a safe integer (reachable at tokens.expires_at), and z.number() rejecting +Infinity.

The lesson worth keeping: a silent tool error and a true negative are indistinguishable in the output. A grep asserting that something is absent needs a positive control — run the pattern against a string you know matches — before the zero means anything. Suppressing stderr makes it strictly worse.

The migration's conclusion is unchanged and PR #163 stands on its own evidence. This corrects the reasoning that got it scheduled first, not the outcome.

## Correction: the "zero breaking patterns" table in the issue body is wrong The table above is not a measurement. Seven of its ten rows come from a command that **errored out and produced no output**, which `wc -l` then counted as zero. ``` $ grep -rP "\.default(" src/ --include=*.ts ugrep: error: error at position 14 (?m)\.default( \___mismatched ( ) ``` An unescaped `(` is an unmatched group in PCRE. Every pattern I wrote containing `(` — `.strict(`, `.passthrough(`, `z.record(`, `.email(`, `.url(`, `.default(`, `.nativeEnum(` — failed the same way, and stderr was suppressed with `2>/dev/null`. Only the three patterns with no parentheses (`invalid_type_error`, `required_error`, `errorMap`) were genuinely searched, and those three really are zero. Corrected, with fixed-string matching: | Pattern | Issue body claimed | Actual | |---|---|---| | `.default(` | 0 | **22** | | `.url(` | 0 | **7** | | `invalid_type_error` | 0 | 0 | | `required_error` | 0 | 0 | | `errorMap` | 0 | 0 | | `.strict(` | 0 | 0 | | `.passthrough(` | 0 | 0 | | `z.record(` | 0 | 0 | | `.email(` | 0 | 0 | | `.nativeEnum(` | 0 | 0 | `.default()` is the row that mattered: v4 changed it from "parse the default through the schema" to "return it directly", and nine of the 22 are in `config.ts` — including `PORT: z.coerce.number().int().positive().default(3001)`, a coerce stacked on a default, which is precisely the shape the semantics change is about. They turned out to be safe because every default is already an output-type value, but that is a conclusion from checking, not from counting. PR #163 found four genuine behaviour changes the bogus table implied could not exist: `z.url()` trimming and accepting unicode whitespace v3 rejected (both reachable at `WORKER_CALLBACK_URL` and `connections.base_url`), `.int()` requiring a *safe* integer (reachable at `tokens.expires_at`), and `z.number()` rejecting `+Infinity`. **The lesson worth keeping:** a silent tool error and a true negative are indistinguishable in the output. A grep asserting that something is *absent* needs a positive control — run the pattern against a string you know matches — before the zero means anything. Suppressing stderr makes it strictly worse. The migration's conclusion is unchanged and PR #163 stands on its own evidence. This corrects the reasoning that got it scheduled first, not the outcome.
lz closed this issue 2026-09-16 11:26:19 +02:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#148
No description provided.