|
| 1 | +# Parsing & Error Handling |
| 2 | + |
| 3 | +Purpose: choose the right parse method, read `ZodError` correctly, format errors for users, and customize messages — with v3 ↔ v4 differences flagged inline. |
| 4 | + |
| 5 | +## Parse methods |
| 6 | + |
| 7 | +```ts |
| 8 | +schema.parse(input); // throws ZodError on failure; returns typed deep clone |
| 9 | +schema.safeParse(input); // returns { success: true, data } | { success: false, error } |
| 10 | +schema.parseAsync(input); // required when schema has async refinements/transforms/codecs |
| 11 | +schema.safeParseAsync(input); // safe variant of the async path |
| 12 | +``` |
| 13 | + |
| 14 | +In v4, codec schemas also expose: |
| 15 | + |
| 16 | +```ts |
| 17 | +schema.decode(input); // strongly-typed input; same runtime behavior as .parse |
| 18 | +schema.encode(value); // reverse direction (output → input) |
| 19 | +schema.safeDecode(input); |
| 20 | +schema.safeEncode(value); |
| 21 | +schema.decodeAsync(input); |
| 22 | +schema.encodeAsync(value); |
| 23 | +``` |
| 24 | + |
| 25 | +Pick `parse` when an exception path is acceptable (e.g. server controllers with framework-level error handlers). Pick `safeParse` for code that must branch on success without exceptions (e.g. form handlers, RPC). |
| 26 | + |
| 27 | +### When to use the async variants |
| 28 | + |
| 29 | +If **any** node in the schema graph is async, you must use `parseAsync` / `safeParseAsync`: |
| 30 | + |
| 31 | +- `.refine(async (val) => ...)` — async refinement |
| 32 | +- `.transform(async (val) => ...)` — async transform |
| 33 | +- `z.codec(..., { decode: async (...) => ..., encode: async (...) => ... })` — async codec |
| 34 | + |
| 35 | +Calling sync `.parse()` on a schema with async checks throws `Synchronous parsing not supported`. The fix is the call site, not the schema. |
| 36 | + |
| 37 | +## Reading `ZodError` |
| 38 | + |
| 39 | +```ts |
| 40 | +import * as z from "zod"; // v4 |
| 41 | + |
| 42 | +const schema = z.strictObject({ |
| 43 | + username: z.string(), |
| 44 | + favoriteNumbers: z.array(z.number()), |
| 45 | +}); |
| 46 | + |
| 47 | +const result = schema.safeParse({ |
| 48 | + username: 1234, |
| 49 | + favoriteNumbers: [1234, "4567"], |
| 50 | + extraKey: 1234, |
| 51 | +}); |
| 52 | + |
| 53 | +if (!result.success) { |
| 54 | + result.error.issues; |
| 55 | + // [ |
| 56 | + // { expected: "string", code: "invalid_type", path: ["username"], |
| 57 | + // message: "Invalid input: expected string, received number" }, |
| 58 | + // { expected: "number", code: "invalid_type", path: ["favoriteNumbers", 1], |
| 59 | + // message: "Invalid input: expected number, received string" }, |
| 60 | + // { code: "unrecognized_keys", keys: ["extraKey"], path: [], |
| 61 | + // message: 'Unrecognized key: "extraKey"' }, |
| 62 | + // ] |
| 63 | +} |
| 64 | +``` |
| 65 | + |
| 66 | +`result.error` is a `z.ZodError` (subclass of `z.core.$ZodError`). Each issue has at least `code`, `path`, and `message`; additional fields depend on the issue code (`expected`, `received`, `keys`, `minimum`, `maximum`, etc.). |
| 67 | + |
| 68 | +> **`zod/mini` users**: parse errors are `z.core.$ZodError`, not `z.ZodError`. Adjust your `instanceof` checks. |
| 69 | +
|
| 70 | +## Formatting errors |
| 71 | + |
| 72 | +### v4 — top-level functions |
| 73 | + |
| 74 | +```ts |
| 75 | +import * as z from "zod"; |
| 76 | + |
| 77 | +const result = schema.safeParse(input); |
| 78 | +if (!result.success) { |
| 79 | + z.treeifyError(result.error); // nested object mirroring schema shape |
| 80 | + z.flattenError(result.error); // { formErrors, fieldErrors } — flat one-level shape |
| 81 | + z.prettifyError(result.error); // human-readable string with bullets and paths |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +`z.treeifyError(result.error)` for the example above returns: |
| 86 | + |
| 87 | +```ts |
| 88 | +{ |
| 89 | + errors: ["Unrecognized key: \"extraKey\""], |
| 90 | + properties: { |
| 91 | + username: { errors: ["Invalid input: expected string, received number"] }, |
| 92 | + favoriteNumbers: { |
| 93 | + errors: [], |
| 94 | + items: [ |
| 95 | + undefined, |
| 96 | + { errors: ["Invalid input: expected number, received string"] }, |
| 97 | + ], |
| 98 | + }, |
| 99 | + }, |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +Access nested errors with optional chaining: `tree.properties?.username?.errors`. |
| 104 | + |
| 105 | +`z.prettifyError(result.error)` returns: |
| 106 | + |
| 107 | +``` |
| 108 | +✖ Unrecognized key: "extraKey" |
| 109 | +✖ Invalid input: expected string, received number |
| 110 | + → at username |
| 111 | +✖ Invalid input: expected number, received string |
| 112 | + → at favoriteNumbers[1] |
| 113 | +``` |
| 114 | + |
| 115 | +`z.formatError(err)` still exists in v4 but is **deprecated** — prefer `z.treeifyError` (the shape changed slightly: `errors`/`properties`/`items` instead of v3's `_errors` underscore convention). |
| 116 | + |
| 117 | +### v3 — instance methods |
| 118 | + |
| 119 | +```ts |
| 120 | +// v3 |
| 121 | +const result = schema.safeParse(input); |
| 122 | +if (!result.success) { |
| 123 | + result.error.format(); // { _errors, [field]: { _errors } } |
| 124 | + result.error.flatten(); // { formErrors, fieldErrors } |
| 125 | +} |
| 126 | +``` |
| 127 | + |
| 128 | +The v3 `format()` shape uses `_errors` as the leaf array on every node: |
| 129 | + |
| 130 | +```ts |
| 131 | +{ |
| 132 | + _errors: ["Unrecognized key: \"extraKey\""], |
| 133 | + username: { _errors: ["Invalid input: expected string, received number"] }, |
| 134 | + favoriteNumbers: { |
| 135 | + _errors: [], |
| 136 | + "1": { _errors: ["Invalid input: expected number, received string"] }, |
| 137 | + } |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +When porting v3 form-handling code to v4, the `_errors` → `errors`/`items` rename is the most common breakage. `flattenError` shape (`{ formErrors, fieldErrors }`) is unchanged. |
| 142 | + |
| 143 | +## Customizing error messages |
| 144 | + |
| 145 | +### v4 — unified `error` param |
| 146 | + |
| 147 | +A single `error` option replaces v3's separate `message` and `errorMap`. It accepts a string or a function. |
| 148 | + |
| 149 | +```ts |
| 150 | +// static string |
| 151 | +z.string({ error: "Bad!" }); |
| 152 | +z.string().min(5, { error: "Too short!" }); |
| 153 | +z.uuid({ error: "Bad UUID!" }); |
| 154 | +z.array(z.string(), { error: "Not an array!" }); |
| 155 | + |
| 156 | +// shorthand: positional string |
| 157 | +z.string("Bad!"); |
| 158 | +z.string().min(5, "Too short!"); |
| 159 | + |
| 160 | +// function form (the v4 "error map") |
| 161 | +z.string({ error: (iss) => iss.input === undefined ? "Required" : "Invalid" }); |
| 162 | + |
| 163 | +// inspect issue context |
| 164 | +z.string().min(5, { |
| 165 | + error: (iss) => { |
| 166 | + iss.code; // issue code |
| 167 | + iss.input; // the input value |
| 168 | + iss.path; // the path within the parent schema |
| 169 | + iss.minimum; // available because we're on .min() |
| 170 | + iss.inclusive; |
| 171 | + return `Must be ≥ ${iss.minimum} chars`; |
| 172 | + }, |
| 173 | +}); |
| 174 | + |
| 175 | +// per-parse override |
| 176 | +schema.parse(input, { error: (iss) => "Custom message" }); |
| 177 | + |
| 178 | +// global override |
| 179 | +z.config({ customError: (iss) => iss.path.length === 0 ? "Top-level error" : undefined }); |
| 180 | +``` |
| 181 | + |
| 182 | +Returning `undefined` from the function falls through to the next map in Zod's precedence chain (schema-level → parse-level → global → default). Use this to selectively override only certain issue codes. |
| 183 | + |
| 184 | +### v3 — separate `message` / `errorMap` |
| 185 | + |
| 186 | +```ts |
| 187 | +// v3 |
| 188 | +z.string({ required_error: "Required", invalid_type_error: "Bad!" }); |
| 189 | +z.string().min(5, { message: "Too short!" }); |
| 190 | + |
| 191 | +// v3 errorMap function |
| 192 | +z.string({ |
| 193 | + errorMap: (iss, ctx) => { |
| 194 | + if (iss.code === "invalid_type") return { message: "Bad!" }; |
| 195 | + return { message: ctx.defaultError }; |
| 196 | + }, |
| 197 | +}); |
| 198 | + |
| 199 | +// v3 per-parse |
| 200 | +schema.parse(input, { errorMap }); |
| 201 | + |
| 202 | +// v3 global |
| 203 | +z.setErrorMap(myErrorMap); |
| 204 | +``` |
| 205 | + |
| 206 | +v3 → v4 cookbook: |
| 207 | + |
| 208 | +| v3 | v4 | |
| 209 | +| --- | --- | |
| 210 | +| `z.string({ required_error, invalid_type_error })` | `z.string({ error: (iss) => iss.input === undefined ? "Required" : "Bad" })` | |
| 211 | +| `z.string({ message: "..." })` | `z.string({ error: "..." })` | |
| 212 | +| `z.string({ errorMap: fn })` | `z.string({ error: fn })` (signature simplified to one arg `iss`) | |
| 213 | +| `z.setErrorMap(fn)` | `z.config({ customError: fn })` | |
| 214 | +| `(iss, ctx) => ({ message: ctx.defaultError })` | `(iss) => undefined` (returning `undefined` defers to default) | |
| 215 | + |
| 216 | +## Common failure modes |
| 217 | + |
| 218 | +1. **`Synchronous parsing not supported`** — schema has async checks; switch caller from `parse` to `parseAsync`. |
| 219 | +2. **`error.format` is not a function** — code on v4 still using v3 instance method; replace with `z.treeifyError(error)`. |
| 220 | +3. **`z.formatError` is deprecated** — switch to `z.treeifyError`. |
| 221 | +4. **`error instanceof z.ZodError === false`** in `zod/mini` — Mini parse errors are `z.core.$ZodError`. Use `error instanceof z.core.$ZodError` or import `ZodError` from the regular package. |
| 222 | +5. **Custom message ignored** — passing v3 `{ message: ... }` shape to a v4 schema. Use `error: ...`. |
| 223 | +6. **`required_error` / `invalid_type_error` not recognized** in v4 — replace with a function `error: (iss) => iss.input === undefined ? "Required" : "Bad"`. |
| 224 | +7. **`ctx.defaultError` undefined in error map** — v4 error maps return `undefined` to defer; there is no `ctx.defaultError`. |
| 225 | +8. **Strict object rejecting valid data with extra fields** — using `z.strictObject` (v4) or `.strict()` (v3). Switch to `.loose()` (v4) / `.passthrough()` (v3) or remove the strictness. |
| 226 | + |
| 227 | +## Issue codes (high-level) |
| 228 | + |
| 229 | +Both versions emit the same conceptual codes, with minor renames in v4. Common ones: |
| 230 | + |
| 231 | +`invalid_type`, `unrecognized_keys`, `invalid_union`, `invalid_value` (v4) / `invalid_literal` (v3), `too_small`, `too_big`, `not_multiple_of`, `invalid_string` (v3), `custom`. |
| 232 | + |
| 233 | +In v4, string format violations use `invalid_format` (e.g. failed `z.email`). In v3 they use `invalid_string` with a `validation` field. |
| 234 | + |
| 235 | +<!-- |
| 236 | +Source references: |
| 237 | +- https://github.com/colinhacks/zod/blob/v4.3.6/packages/docs/content/basics.mdx |
| 238 | +- https://github.com/colinhacks/zod/blob/v4.3.6/packages/docs/content/error-formatting.mdx |
| 239 | +- https://github.com/colinhacks/zod/blob/v4.3.6/packages/docs/content/error-customization.mdx |
| 240 | +- https://github.com/colinhacks/zod/blob/v4.3.6/packages/docs/content/api.mdx |
| 241 | +- https://github.com/colinhacks/zod/tree/v3.25.76/packages/docs/content |
| 242 | +- https://github.com/colinhacks/zod/blob/v3.25.76/packages/docs/content/error-customization.mdx |
| 243 | +--> |
0 commit comments