Migrate to zod v4 #148
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lz/agent-nexus#148
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Prerequisite for MCP support.
@modelcontextprotocol/serverv2.0.0 depends onzod ^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 wrongz.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:
v4 keeps every one of those.
Measured: zero occurrences of every classic v3→v4 breaking pattern.
invalid_type_errorrequired_errorerrorMap.strict(.passthrough(z.record(.email(/.url(.default(.nativeEnum(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
zodinnexus/package.json; add@modelcontextprotocol/core/@modelcontextprotocol/serveronly in the MCP issue, not here.pnpm typecheck+pnpm testgreen.message:occurrences — most are not zod at all, but a{ message }inside a.refine()should become{ error }where v4 prefers it.z.coercebehaviour is unchanged for the two call sites that use it; v4 tightened coercion.config.tsis 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 testandpnpm lintare green on zod 4, and adocker compose up -dboots without a config validation error.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 -lthen counted as zero.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 with2>/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:
.default(.url(invalid_type_errorrequired_errorerrorMap.strict(.passthrough(z.record(.email(.nativeEnum(.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 inconfig.ts— includingPORT: 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 atWORKER_CALLBACK_URLandconnections.base_url),.int()requiring a safe integer (reachable attokens.expires_at), andz.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.