CodeSampleX

Exemple

zod 4.4.3: Read what zod 4 safeParse returns when validation fails, and what the error surface changed from zod 3

Échantillon vérifié pour npm zod 4.4.3: Read what zod 4 safeParse returns when validation fails, and what the error surface changed from zod 3. Le contrat…

sha256:a382ae2a31a7ab492fdd32bd455c4460df064c7a5fc94698cef12db9624d30a2

Ce réseau offre une seule chose : un échantillon qui compile. Il l'a exécuté dans un bac à sable et conservé le reçu signé. Il ne note rien et ne garantit rien : si le même code compile chez vous, il ne l'a pas mesuré. Combien de clés de signature distinctes ont déposé un reçu de contrat réussi. Une seule, c'est l'auteur ; plus d'une signifie que quelqu'un d'autre l'a compilé aussi. Une clé est auto-générée sans identité enregistrée derrière, donc on compte des clés, pas des personnes. MIT-0

Preuves d'exécution

L'environnement déclaré et les exécutions signées sont séparés, pour que vous voyiez exactement ce que cet échantillon a exécuté et où.

Base de preuve
Contrat signé réussi
Reçus de vérification
3
Clés de signature qui l’ont compilé
3
Environnement déclaré node 22 linux x64 node 22 javascript npm

Environnements des exécutions de vérification

Environnement Contrat Étapes Exécution
node 22 · linux alpine/x64 · docker ed25519:a2ec939a4c60e243 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · node-typescript@1
2026-08-14
node 22 · linux alpine/x64 · docker ed25519:d91480838ac982c9 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · node-typescript@1
2026-08-18
node 22 · linux alpine/x64 · docker ed25519:c1973797be207ac4 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · node-typescript@1node:22-alpine@sha256:c610fcdfb1d5…
2026-09-07

Cas

HOW
Objectif
Read what zod 4 safeParse returns when validation fails, and what the error surface changed from zod 3
Paquets
Symboles
  • safeParse
  • ZodError.issues
  • ZodIssue.received
  • z.coerce.number
  • z.union
  • z.discriminatedUnion
  • z.email
Environnement
node 22
Créé
2026-08-14T12:07:51Z

Contrat

  1. assert safeParse returns success plus data or success plus error, and the failing result carries no data key at all
  2. assert result.error is a ZodError, is instanceof ZodError and instanceof Error, and is named ZodError
  3. assert every issue carries code, path and message, and that an object schema reports every bad field in one pass
  4. assert an invalid_type issue carries expected but no received, and that error.message is the issues array serialised as JSON
  5. assert a path through an object inside an array reports the index as a number, so a path is (string | number)[]
  6. assert dropping the non-string path segments collapses two bad array rows onto one key, turning two errors into one
  7. assert a failure with nothing to blame carries an empty path, so joining a path needs a fallback
  8. assert an out-of-range number fails with too_small carrying origin, minimum and inclusive, not invalid_type
  9. assert z.coerce.number() accepts the string 42 while z.number() rejects it as invalid_type
  10. assert z.coerce.number() also accepts empty string, whitespace, null, false and [] as 0, and rejects 1e999 because Number() overflows to Infinity
  11. assert received is carried only when the input is already the right type but an unrepresentable value (NaN, Infinity, Invalid Date), and is absent for a wrong type even under coercion
  12. assert optional() omits a missing key, keeps a key passed as undefined, and rejects null
  13. assert nullable() accepts null but still requires the key, and default() replaces undefined only, never null
  14. assert an ambiguous union fails with one invalid_union issue holding one issue group per member in declaration order, named errors and not unionErrors
  15. assert a union where exactly one member survives its type checks reports that member's issues directly, with no invalid_union wrapper
  16. assert two surviving members bring the wrapper back, so one schema returns both shapes for different bad inputs
  17. assert a member failing only a length check survives and is reported bare, while a member failing only a literal is aborted and the wrapper returns
  18. assert a tagged z.union narrows the same way z.discriminatedUnion does, and that both are the same union type underneath
  19. assert an unknown discriminator names the discriminator and lists options, while the plain union falls back to every member group
  20. assert a literal mismatch is invalid_value carrying values, not zod 3's invalid_literal carrying expected
  21. assert error.errors from zod 3 is absent rather than a deprecated alias for error.issues
  22. assert z.string().email() still exists alongside z.email() and produces an issue deep-equal to it, while z.string().ip() really was removed in favour of z.ipv4

Fichiers

  • csx.json
  • package-lock.json
  • package.json
  • src/schema.mjs
  • test/contract.mjs

Télécharger l’artefact source (tar.gz)

Code source

csx.json
{"case":{"caseId":"case:sha256:73fdc1274872fa07d685d1476032b93fff8a4e1cebee43206b72c501866ef7e1","constraints":{"moduleSystem":"esm","runtime":"node"},"contract":["assert safeParse returns success plus data or success plus error, and the failing result carries no data key at all","assert result.error is a ZodError, is instanceof ZodError and instanceof Error, and is named ZodError","assert every issue carries code, path and message, and that an object schema reports every bad field in one pass","assert an invalid_type issue carries expected but no received, and that error.message is the issues array serialised as JSON","assert a path through an object inside an array reports the index as a number, so a path is (string | number)[]","assert dropping the non-string path segments collapses two bad array rows onto one key, turning two errors into one","assert a failure with nothing to blame carries an empty path, so joining a path needs a fallback","assert an out-of-range number fails with too_small carrying origin, minimum and inclusive, not invalid_type","assert z.coerce.number() accepts the string 42 while z.number() rejects it as invalid_type","assert z.coerce.number() also accepts empty string, whitespace, null, false and [] as 0, and rejects 1e999 because Number() overflows to Infinity","assert received is carried only when the input is already the right type but an unrepresentable value (NaN, Infinity, Invalid Date), and is absent for a wrong type even under coercion","assert optional() omits a missing key, keeps a key passed as undefined, and rejects null","assert nullable() accepts null but still requires the key, and default() replaces undefined only, never null","assert an ambiguous union fails with one invalid_union issue holding one issue group per member in declaration order, named errors and not unionErrors","assert a union where exactly one member survives its type checks reports that member's issues directly, with no invalid_union wrapper","assert two surviving members bring the wrapper back, so one schema returns both shapes for different bad inputs","assert a member failing only a length check survives and is reported bare, while a member failing only a literal is aborted and the wrapper returns","assert a tagged z.union narrows the same way z.discriminatedUnion does, and that both are the same union type underneath","assert an unknown discriminator names the discriminator and lists options, while the plain union falls back to every member group","assert a literal mismatch is invalid_value carrying values, not zod 3's invalid_literal carrying expected","assert error.errors from zod 3 is absent rather than a deprecated alias for error.issues","assert z.string().email() still exists alongside z.email() and produces an issue deep-equal to it, while z.string().ip() really was removed in favour of z.ipv4"],"goal":"Read what zod 4 safeParse returns when validation fails, and what the error surface changed from zod 3","kind":"HOW","packages":["pkg:npm/zod@4.4.3"],"schemaVersion":1,"symbols":["safeParse","ZodError.issues","ZodIssue.received","z.coerce.number","z.union","z.discriminatedUnion","z.email"]},"contractCommand":["node","test/contract.mjs"],"environment":{"arch":"x64","ecosystem":"npm","executionContext":"node","language":"javascript","moduleSystem":"esm","os":"linux","packageManager":"npm","runtime":"node","runtimeVersion":"22","schemaVersion":1},"license":"MIT-0","packages":["pkg:npm/zod@4.4.3"],"schemaVersion":1,"symbols":["safeParse","ZodError.issues","ZodIssue.received","z.coerce.number","z.union","z.discriminatedUnion","z.email"],"verifierAdapter":"node-typescript@1"}
package-lock.json
{
  "name": "csx-zod4-error-shape",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "csx-zod4-error-shape",
      "version": "1.0.0",
      "license": "MIT-0",
      "dependencies": {
        "zod": "4.4.3"
      }
    },
    "node_modules/zod": {
      "version": "4.4.3",
      "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
      "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/colinhacks"
      }
    }
  }
}
package.json
{
  "name": "csx-zod4-error-shape",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "license": "MIT-0",
  "dependencies": {
    "zod": "4.4.3"
  }
}
src/schema.mjs
import { z } from "zod";

/**
 * What zod 4 hands you when validation fails.
 *
 * A failed validation comes back as a value rather than an exception:
 * safeParse returns `{ success: true, data }` or `{ success: false, error }`,
 * and the losing branch does not carry the other key at all, so there is no
 * partial `data` to inspect after a failure. The error is a ZodError, a real
 * Error subclass, and everything useful about the failure lives on
 * `error.issues`.
 *
 * The zod 3 spelling `error.errors` is gone in 4 — not deprecated, absent —
 * and so is `issue.received` on an ordinary type mismatch. Both are measured
 * in the contract, because code that reads them gets `undefined` rather than
 * a crash, which is how a migration ships a silently empty error page.
 */

export const LineItem = z.object({
  sku: z.string(),
  qty: z.number().int().positive(),
});

export const Order = z.object({
  id: z.string(),
  items: z.array(LineItem),
});

/**
 * The three ways a field can be "not required", which are three different
 * things and not interchangeable. optional() and default() react to
 * undefined; nullable() reacts to null. Neither covers the other.
 */
export const Profile = z.object({
  nickname: z.string().optional(),
  bio: z.string().nullable(),
  locale: z.string().default("en"),
});

/**
 * How a union says which member failed — and it does not always answer with
 * the same shape, which is the part that surprises people.
 *
 * Measured rule, arrived at by trying the combinations rather than by reading
 * the changelog: zod 4 counts the members whose failures were all continuable
 * — a length, a format, the constraint checks that let parsing carry on and
 * collect more. A wrong type aborts a member outright, and so does a wrong
 * literal; the contract measures the literal case, because that is the half of
 * the rule most likely to be guessed the other way. When exactly one member is
 * left standing, zod treats it as the branch you meant and returns that
 * member's issues directly, with paths already relative to the whole input and
 * no wrapper at all. When none is left, or when more than one is, there is no
 * branch to prefer and you get a single invalid_union issue carrying one group
 * of issues per member, in declaration order. Nothing labels a group with the
 * member it came from, so its index is the only handle you get.
 *
 * Code that reaches straight for `issues[0].errors` is therefore reading a
 * union error that may never arrive, and code that assumes `issues[0].path`
 * points at a field breaks on the ambiguous input.
 */
export const Contact = z.union([
  z.object({ email: z.email() }),
  z.object({ phone: z.string().min(7) }),
]);

/**
 * A shared literal key is the reliable way to leave exactly one member
 * standing: a tag it does not match aborts that member, so the member the tag
 * names is the only one still holding continuable failures. That is why a
 * tagged plain z.union already reports like a discriminated one — same issues,
 * and the same union type underneath.
 *
 * The two diverge on one input: a tag matching no member. The plain union has
 * nothing to prefer and dumps every member's issues; the declared version
 * names the discriminator and lists the tags it accepts. That case, not the
 * ordinary one, is what declaring it buys.
 */
export const Payment = z.union([
  z.object({ kind: z.literal("card"), last4: z.string().length(4) }),
  z.object({ kind: z.literal("iban"), iban: z.string().min(15) }),
]);

export const TaggedPayment = z.discriminatedUnion("kind", [
  z.object({ kind: z.literal("card"), last4: z.string().length(4) }),
  z.object({ kind: z.literal("iban"), iban: z.string().min(15) }),
]);

/**
 * Turning issues into something a form can render.
 *
 * `issue.path` is an array of the keys walked to reach the value, and array
 * indices arrive as numbers, not strings. Joining is fine; filtering the path
 * to strings is the mistake, because it collapses items[0] and items[1] onto
 * the same key.
 */
export function fieldErrors(error) {
  const byField = {};
  for (const issue of error.issues) {
    const key = issue.path.length === 0 ? "(root)" : issue.path.join(".");
    (byField[key] ??= []).push(issue.message);
  }
  return byField;
}
test/contract.mjs
import assert from "node:assert/strict";
import { z } from "zod";

import {
  Contact,
  fieldErrors,
  Order,
  Payment,
  Profile,
  TaggedPayment,
} from "../src/schema.mjs";

assert.deepEqual(z.core.version, { major: 4, minor: 4, patch: 3 });

// ---------------------------------------------------------------------------
// The result object
// ---------------------------------------------------------------------------

const ok = Order.safeParse({ id: "A1", items: [{ sku: "s", qty: 2 }] });
assert.equal(ok.success, true);
assert.deepEqual(Object.keys(ok), ["success", "data"]);

// The failing branch carries no `data` key at all, so `result.data` after a
// failure is undefined rather than a partial object. There is no partial
// object to reach for: zod either returns the whole parsed value or none of it.
const bad = Order.safeParse({ id: 7, items: "none" });
assert.equal(bad.success, false);
assert.deepEqual(Object.keys(bad), ["success", "error"]);
assert.equal("data" in bad, false);

// The error is a ZodError and a real Error, so `catch (e)` narrowing works and
// so does rethrowing it through code that only understands Error.
assert.ok(bad.error instanceof z.ZodError);
assert.ok(bad.error instanceof Error);
assert.equal(bad.error.name, "ZodError");

// Every issue carries these four. `code` is a stable string, `path` is the
// walk to the value, `message` is already human-readable English.
assert.equal(bad.error.issues.length, 2);
for (const issue of bad.error.issues) {
  assert.equal(typeof issue.code, "string");
  assert.equal(typeof issue.message, "string");
  assert.ok(Array.isArray(issue.path));
}

// Both bad fields are reported. An object schema does not stop at the first
// failure, which is what makes rendering a whole form in one pass possible.
assert.deepEqual(
  bad.error.issues.map((i) => i.path[0]).sort(),
  ["id", "items"],
);

// A type mismatch is `invalid_type` and states what it wanted. What it got is
// only in the prose message: zod 4 dropped `received` from the issue object
// for an ordinary wrong-type input, so a zod 3 renderer that prints
// `issue.received` prints undefined for every mismatch of this kind. The
// narrow set of issues that do still carry it is measured further down.
const idIssue = bad.error.issues.find((i) => i.path[0] === "id");
assert.equal(idIssue.code, "invalid_type");
assert.equal(idIssue.expected, "string");
assert.deepEqual(Object.keys(idIssue), ["expected", "code", "path", "message"]);
assert.equal(idIssue.received, undefined);
assert.equal(idIssue.message, "Invalid input: expected string, received number");

// error.message is the issues array serialised as JSON, not a one-line
// summary. Logging `err.message` dumps the whole array into your log line.
assert.deepEqual(JSON.parse(bad.error.message), bad.error.issues);

// ---------------------------------------------------------------------------
// Paths through arrays
// ---------------------------------------------------------------------------

const nested = Order.safeParse({
  id: "A1",
  items: [{ sku: "s", qty: 1 }, { sku: "t", qty: "two" }],
});
const deep = nested.error.issues[0];

// The index is a number in the path, not the string "1". Everything else is a
// string key, so a path is (string | number)[] and typing it as string[] is
// wrong.
assert.deepEqual(deep.path, ["items", 1, "qty"]);
assert.equal(typeof deep.path[1], "number");
assert.deepEqual(fieldErrors(nested.error), {
  "items.1.qty": ["Invalid input: expected number, received string"],
});

// Why keeping the index matters: dropping the non-string segments to get a
// "field name" maps both bad rows onto items.qty, and the form shows one error
// where there were two.
const twoRows = Order.safeParse({
  id: "A1",
  items: [{ sku: "s", qty: "one" }, { sku: "t", qty: "two" }],
});
assert.equal(twoRows.error.issues.length, 2);
assert.deepEqual(Object.keys(fieldErrors(twoRows.error)), ["items.0.qty", "items.1.qty"]);
assert.deepEqual(
  [...new Set(twoRows.error.issues.map((i) =>
    i.path.filter((s) => typeof s === "string").join(".")))],
  ["items.qty"],
);

// A value that is the right type but out of range fails differently: the code
// is a bound name rather than invalid_type, and the issue carries the bound it
// checked plus an `origin` naming the datatype it applies to. too_small covers
// a short string and a small number alike, so `origin` is what tells the two
// apart when you are writing the message.
const small = Order.safeParse({ id: "A1", items: [{ sku: "s", qty: 0 }] })
  .error.issues[0];
assert.equal(small.code, "too_small");
assert.equal(small.origin, "number");
assert.equal(small.minimum, 0);
assert.equal(small.inclusive, false);
assert.deepEqual(small.path, ["items", 0, "qty"]);

// A failure with nothing to blame has an empty path, which is why joining it
// needs a fallback rather than producing "".
const rootIssue = Order.safeParse("not an object").error.issues[0];
assert.deepEqual(rootIssue.path, []);
assert.deepEqual(Object.keys(fieldErrors(Order.safeParse("x").error)), ["(root)"]);

// ---------------------------------------------------------------------------
// z.coerce.number()
// ---------------------------------------------------------------------------

// The half everyone knows: a query-string "42" fails a number and passes a
// coerced one.
assert.equal(z.number().safeParse("42").success, false);
assert.equal(z.number().safeParse("42").error.issues[0].code, "invalid_type");
assert.deepEqual(z.coerce.number().safeParse("42"), { success: true, data: 42 });

// The half that bites: z.coerce.number() is `Number(input)` followed by the
// number check, not a numeric parser. Number("") is 0, Number(null) is 0,
// Number(false) is 0, Number([]) is 0 — so every one of these is accepted as
// a valid number and a missing field silently becomes zero. Coerce at the
// edge you actually control, and check for emptiness before you coerce.
for (const input of ["", "   ", null, false, []]) {
  const r = z.coerce.number().safeParse(input);
  assert.equal(r.success, true, `expected ${JSON.stringify(input)} to coerce`);
  assert.equal(r.data, 0);
}
assert.equal(z.coerce.number().safeParse(true).data, 1);
assert.equal(z.coerce.number().safeParse("0x10").data, 16);

// It fails when Number() gives something the number type cannot represent as a
// finite value, which is NaN *or* Infinity — "1e999" overflows and is rejected,
// not accepted as a big number.
const nan = z.coerce.number().safeParse("abc");
assert.equal(nan.success, false);
assert.equal(nan.error.issues[0].code, "invalid_type");
assert.equal(nan.error.issues[0].expected, "number");
assert.equal(nan.error.issues[0].received, "NaN");
assert.equal(z.coerce.number().safeParse("1e999").error.issues[0].received, "Infinity");

// These are the issues that keep `received`, and coercion is not what decides
// it. The rule is narrower and worth knowing before you write the renderer:
// `received` appears only when the input is already the right JS type but
// holds a value that type cannot validly express — a number that is NaN or
// Infinity, a Date that is Invalid Date. Nothing else in zod 4 sets it, so
// `received` is a description of a bad value, never of a wrong type.
assert.equal(z.number().safeParse(NaN).error.issues[0].received, "NaN");
assert.equal(z.number().safeParse(Infinity).error.issues[0].received, "Infinity");
assert.equal(z.date().safeParse(new Date("nope")).error.issues[0].received, "Invalid Date");
assert.equal(z.date().safeParse("2020-01-01").error.issues[0].received, undefined);

// A symbol is the case that shows coercion alone does not add `received`:
// Number(Symbol()) throws, zod swallows it, and the still-uncoerced symbol
// fails as an ordinary wrong type with no `received` at all.
const sym = z.coerce.number().safeParse(Symbol("s"));
assert.equal(sym.success, false);
assert.equal(sym.error.issues[0].received, undefined);
assert.deepEqual(Object.keys(sym.error.issues[0]), ["expected", "code", "path", "message"]);

// ---------------------------------------------------------------------------
// optional vs nullable vs default
// ---------------------------------------------------------------------------

// Absent key: optional passes and stays absent, default fires, nullable fails.
// nullable is not "not required" — it accepts null and still demands the key.
const absent = Profile.safeParse({});
assert.equal(absent.success, false);
assert.deepEqual(absent.error.issues.map((i) => i.path), [["bio"]]);
assert.equal(absent.error.issues[0].expected, "string");

const minimal = Profile.safeParse({ bio: null });
assert.deepEqual(minimal.data, { bio: null, locale: "en" });
// The optional key is genuinely missing from the output, not present-as-undefined,
// so `"nickname" in profile` is false and JSON.stringify omits it.
assert.equal(Object.hasOwn(minimal.data, "nickname"), false);

// Pass the key explicitly as undefined and zod 4 keeps it. The output shape
// therefore mirrors the input shape, which matters if you diff parsed objects
// or hand them to something that distinguishes missing from undefined.
const explicit = Profile.safeParse({ nickname: undefined, bio: null });
assert.equal(Object.hasOwn(explicit.data, "nickname"), true);
assert.equal(explicit.data.nickname, undefined);
assert.deepEqual(Object.keys(explicit.data).sort(), ["bio", "locale", "nickname"]);

// default() replaces undefined, however the undefined arrived, and never null.
assert.equal(Profile.safeParse({ bio: null, locale: undefined }).data.locale, "en");
assert.equal(Profile.safeParse({ bio: null, locale: "ko" }).data.locale, "ko");

// The symmetry, stated as the failures: optional rejects null, nullable
// rejects undefined, default rejects null. Only nullable admits null.
assert.equal(z.string().optional().safeParse(null).success, false);
assert.equal(z.string().nullable().safeParse(undefined).success, false);
assert.equal(z.string().default("d").safeParse(null).success, false);
assert.equal(z.string().nullable().safeParse(null).data, null);
assert.equal(z.string().optional().safeParse(undefined).data, undefined);
assert.equal(z.string().default("d").safeParse(undefined).data, "d");

// ---------------------------------------------------------------------------
// Which union member failed
// ---------------------------------------------------------------------------

// Nothing survived: every member aborted on a type check, so there is no
// branch to prefer. One invalid_union issue at the root, one issue group per
// member, in declaration order.
const noMatch = Contact.safeParse({});
assert.equal(noMatch.error.issues.length, 1);

const unionIssue = noMatch.error.issues[0];
assert.equal(unionIssue.code, "invalid_union");
assert.deepEqual(unionIssue.path, []);
assert.equal(unionIssue.errors.length, 2);
assert.ok(unionIssue.errors.every(Array.isArray));

// zod 3 called this `unionErrors` and filled it with ZodError objects. In 4 it
// is `errors` and the entries are plain issue arrays, so the ZodError methods
// people call on them are not there to call.
assert.equal("unionErrors" in unionIssue, false);
assert.ok(!(unionIssue.errors[0] instanceof z.ZodError));

// The index is what identifies the branch: group 0 is the email member missing
// its key, group 1 the phone member missing its key. Nothing else says so.
assert.deepEqual(unionIssue.errors[0].map((i) => [i.path[0], i.code]), [
  ["email", "invalid_type"],
]);
assert.deepEqual(unionIssue.errors[1].map((i) => [i.path[0], i.code]), [
  ["phone", "invalid_type"],
]);

// Exactly one member survived. The expectation here was another invalid_union
// wrapper, since neither member parsed. Measured otherwise: the phone member
// aborts on the missing key, leaving the email member as the only one whose
// failure was continuable, so zod reports its issue directly, at the real
// field path, with no wrapper and no `errors` to unpack.
const oneMatch = Contact.safeParse({ email: "nope" });
assert.equal(oneMatch.error.issues.length, 1);
assert.equal(oneMatch.error.issues[0].code, "invalid_format");
assert.deepEqual(oneMatch.error.issues[0].path, ["email"]);
assert.equal(oneMatch.error.issues[0].errors, undefined);

// Two members survived, so the tie is unresolvable and the wrapper is back.
// Same schema, same kind of bad input, different error shape — this is why the
// union branch of an error renderer needs both cases.
const twoMatch = Contact.safeParse({ email: "nope", phone: "12" });
assert.equal(twoMatch.error.issues[0].code, "invalid_union");
assert.equal(twoMatch.error.issues[0].errors.length, 2);

// Which failures leave a member standing, measured against a member that can
// only ever fail one check. A length check is continuable, so that member is
// the sole survivor and its issue is returned bare. A wrong literal is not: it
// aborts its member exactly the way a wrong type does, nobody survives, and
// the wrapper comes back. Counting a literal among the continuable checks
// predicts the opposite result for this input, and getting it right is what
// explains the tag: a tag narrows by eliminating the members it does not
// match, not by ranking them.
const survivor = z.union([
  z.object({ kind: z.literal("card"), last4: z.string().length(4) }),
  z.object({ other: z.number() }),
]);
assert.equal(survivor.safeParse({ kind: "card", last4: "1" }).error.issues[0].code, "too_small");

const noSurvivor = z.union([
  z.object({ kind: z.literal("card"), last4: z.string() }),
  z.object({ other: z.number() }),
]);
const litFail = noSurvivor.safeParse({ kind: "cash", last4: "1234" }).error.issues[0];
assert.equal(litFail.code, "invalid_union");
assert.deepEqual(litFail.errors[0].map((i) => i.code), ["invalid_value"]);

// A shared literal tag is the dependable way to leave exactly one member
// standing, so a plain z.union of tagged objects already narrows: one issue,
// at the field that failed inside the member the tag names.
const union = Payment.safeParse({ kind: "card", last4: "12" });
assert.equal(union.error.issues.length, 1);
assert.equal(union.error.issues[0].code, "too_small");
assert.deepEqual(union.error.issues[0].path, ["last4"]);

// Declaring the discriminator produces exactly the same issue, and both
// schemas are the same union type underneath.
const tagged = TaggedPayment.safeParse({ kind: "card", last4: "12" });
assert.deepEqual(tagged.error.issues, union.error.issues);
assert.equal(TaggedPayment._zod.def.type, "union");
assert.equal(Payment._zod.def.type, "union");

// The one input that separates them: a tag matching no member. The plain union
// has nothing to narrow to and falls back to every member's issues, which is
// the unreadable output people blame unions for.
const looseUnknown = Payment.safeParse({ kind: "cash" }).error.issues[0];
assert.equal(looseUnknown.code, "invalid_union");
assert.deepEqual(looseUnknown.path, []);
assert.equal(looseUnknown.errors.length, 2);

// The declared version answers the actual question instead: one issue, aimed
// at the discriminator, carrying the tags it accepts and no member groups.
const unknownTag = TaggedPayment.safeParse({ kind: "cash" }).error.issues[0];
assert.equal(unknownTag.code, "invalid_union");
assert.deepEqual(unknownTag.path, ["kind"]);
assert.equal(unknownTag.discriminator, "kind");
assert.deepEqual(unknownTag.options, ["card", "iban"]);
assert.deepEqual(unknownTag.errors, []);
assert.equal(unknownTag.message, "Invalid discriminator value. Expected 'card' | 'iban'");

// A literal that does not match is `invalid_value` carrying the allowed
// `values`. zod 3 called it `invalid_literal` and carried `expected`.
const literalIssue = looseUnknown.errors[0].find((i) => i.path[0] === "kind");
assert.equal(literalIssue.code, "invalid_value");
assert.deepEqual(literalIssue.values, ["card"]);

// ---------------------------------------------------------------------------
// The two zod 3 to 4 migration answers, measured
// ---------------------------------------------------------------------------

// error.errors is gone. It is not a deprecated alias for issues, it is not
// defined at all, so `error.errors.map(...)` throws TypeError on undefined and
// `error.errors?.length` quietly reports zero problems. Rename to .issues.
assert.equal("errors" in bad.error, false);
assert.equal(bad.error.errors, undefined);
assert.equal(bad.error.issues.length, 2);

// z.string().email() has NOT been removed. Both spellings exist in 4.4.3 and
// produce the identical issue, so the top-level z.email() is the preferred
// form rather than a required rewrite. What did change is the issue: zod 3
// reported code "invalid_string" with validation "email"; zod 4 reports
// "invalid_format" with format "email" and an `origin`.
assert.equal(typeof z.email, "function");
assert.equal(typeof z.string().email, "function");
const viaMethod = z.string().email().safeParse("nope").error.issues[0];
const viaTopLevel = z.email().safeParse("nope").error.issues[0];
assert.equal(viaMethod.code, "invalid_format");
assert.equal(viaMethod.format, "email");
assert.equal(viaMethod.origin, "string");
assert.equal(viaMethod.message, "Invalid email address");
// Identical down to the compiled `pattern`, so the two spellings are the same
// validator and not merely similar ones.
assert.deepEqual(viaMethod, viaTopLevel);
assert.equal(z.email().safeParse("orders@example.com").success, true);
assert.equal(z.string().email().safeParse("orders@example.com").success, true);

// "The string methods all moved to the top level" is too strong as a rule, and
// too weak as a reassurance. Some were kept as aliases and at least one really
// was deleted: .ip() is gone, replaced by z.ipv4() and z.ipv6(), so that one
// is a hard break while .email() is not.
assert.equal(z.string().ip, undefined);
assert.equal(typeof z.ipv4, "function");
assert.equal(typeof z.ipv6, "function");

console.log("contract ok: zod", Object.values(z.core.version).join("."));

Seeder d'origine

anonymous