TypeScript

Fix Zod 4: errors Does Not Exist on ZodError

Repair the missing errors property without losing nested paths, hiding validation failures or turning unexpected exceptions into form errors.

Mahdi Ben RhoumaMahdi Ben Rhouma
9 min read

TL;DR#

Zod 4 removed the ZodError.errors alias. Replace it with error.issues, inside the failed branch of safeParse or after narrowing a caught value to z.ZodError. The issue array still contains structured validation details. Choose a formatter separately when a form needs messages grouped by field; a property rename alone does not decide how nested errors should appear.

The compiler failure reproduced with a string schema is:

text
bad.ts(3,47): error TS2339: Property 'errors' does not exist on type 'ZodError<string>'.

In JavaScript, the same access returns undefined. Calling map on it produced:

text
TypeError: Cannot read properties of undefined (reading 'map')

These results were reproduced on 2 November 2026 with Zod 4.6.7, TypeScript 7.0.2 and Node.js 24.22.2 in a node:24-alpine Docker container. The missing-property error disappeared after changing only errors to issues. No compiler suppression or schema change was needed.

Why it happens#

Zod issue #5063 records a migration where code using the old alias stopped working. The report also describes a documentation gap at the time. The current official migration guide now explicitly documents the removal and directs callers to issues. Treat the issue as the original incident, and the current guide as the API reference.

This is a runtime API removal as well as a type error. An assertion, an interface extension or a compiler suppression can convince TypeScript that a property exists without creating it on the object. That leaves JavaScript reading undefined from the same expression.

The distinction matters if the wording reminds you of adding a custom property to Window in TypeScript. That article uses declaration merging to describe an application-owned runtime property. Here the library stopped supplying the alias; adding a declaration cannot restore the library behaviour. Read the existing issue array instead.

Keep three objects distinct while editing: the result from safeParse, the ZodError held in its failure branch, and any formatted object derived from that error. They have different shapes, even though several properties contain error information.

Fix#

1. Change the property inside the failure branch#

This complete example triggers the failure:

ts
import * as z from 'zod';
const result = z.string().safeParse(42);
if (!result.success) console.log(result.error.errors);

Change only the last property access:

ts
import * as z from 'zod';
const result = z.string().safeParse(42);
if (!result.success) console.log(result.error.issues);

The corrected file type-checks and exposes the same validation failure. It does not make the number a valid string: successful migration means your error handler can read the rejected input's issues again. Converting the input, weakening the schema or returning an empty array would change the validation contract instead.

For a larger application, search error handlers and adapters that read this alias, then inspect each result. Avoid replacing every occurrence of the word errors: a response object or formatter may legitimately use that name. A form library's public error state is also separate from the ZodError instance.

2. Preserve the typed validation boundary#

Zod's basic usage documentation describes safeParse as a discriminated union. Its success property determines whether you should consume data or error. Keep that check adjacent to the access:

ts
import * as z from 'zod';
 
const Account = z.object({ email: z.email() });
 
export function validateAccount(input: unknown) {
  const result = Account.safeParse(input);
  if (!result.success) {
    return { success: false as const, issues: result.error.issues };
  }
  return { success: true as const, data: result.data };
}

The returned success flag also lets the caller narrow your own response. The validated data comes from result.data, while input remains unknown at the entry point. Passing the original input onwards would discard the benefit of the validation boundary, particularly when a schema transforms a value.

If existing code uses parse rather than safeParse, narrow the caught value before inspecting it:

ts
import * as z from 'zod';
 
const Account = z.object({ email: z.email() });
 
export function parseAccount(input: unknown) {
  try {
    return { success: true as const, data: Account.parse(input) };
  } catch (error: unknown) {
    if (!(error instanceof z.ZodError)) throw error;
    return { success: false as const, issues: error.issues };
  }
}

The guard identifies a validation exception. Rethrowing other failures prevents an unexpected exception from being presented as a user's invalid email. TypeScript documents instanceof narrowing; asserting a caught value to ZodError provides no equivalent runtime check.

In a codebase being converted from JavaScript, unchecked handlers can hide this removal until runtime. The site's incremental JavaScript-to-TypeScript migration guide covers introducing checking gradually. Include the validation adapters in that work: a correctly typed schema does not help if its consumer erases the result to any.

3. Return both field and form messages#

A form usually needs more than the first issue's message. Some errors belong to a field, while an issue at the empty path belongs to the form as a whole. Keeping only fieldErrors can silently discard that second category.

This complete example deliberately uses a strict object, so an unexpected key creates a root-level error alongside invalid fields. It also contains nested data to show what flattening loses:

ts
import * as z from 'zod';
 
const Form = z.strictObject({
  email: z.email(),
  profile: z.object({ name: z.string().min(2) }),
  scores: z.array(z.number()),
});
 
function validate(input: unknown) {
  const result = Form.safeParse(input);
  if (!result.success) {
    return { success: false as const, ...z.flattenError(result.error) };
  }
  return { success: true as const, data: result.data };
}
 
const input = {
  email: 'bad',
  profile: { name: '' },
  scores: [1, 'bad'],
  extra: true,
};
 
const result = Form.safeParse(input);
if (!result.success) {
  const tree = z.treeifyError(result.error);
  console.log('flatten:', JSON.stringify(z.flattenError(result.error)));
  console.log('nested name:', JSON.stringify(
    tree.properties?.profile?.properties?.name?.errors
  ));
  console.log('second score:', JSON.stringify(
    tree.properties?.scores?.items?.[1]?.errors
  ));
}
console.log('form response:', JSON.stringify(validate(input)));
console.log('valid:', JSON.stringify(validate({
  email: 'ada@example.eu', profile: { name: 'Ada' }, scores: [1, 2],
})));

The reproduced flattened output was:

text
flatten: {"formErrors":["Unrecognized key: \"extra\""],"fieldErrors":{"email":["Invalid email address"],"profile":["Too small: expected string to have >=2 characters"],"scores":["Invalid input: expected number, received string"]}}

Return both arrays of field messages and the form-level messages to your UI boundary. Render formErrors in a visible summary and the appropriate fieldErrors beside controls. Using arrays avoids dropping additional messages when several checks fail for one field. This example returns plain data; connect that shape to your chosen form library's documented adapter rather than assuming its state has the same layout.

Keep nested paths when the control needs them#

The official formatting reference offers distinct representations. Raw issues retain a code, message and structured path. In the reproduced example, the profile issue's path was an array containing profile and name; the score issue contained scores followed by the numeric array index. The unknown extra key generated an empty path.

Flattening grouped the nested name message under profile and the array element message under scores. That is useful for a section-level summary, but insufficient to identify a particular nested control. Use treeifyError or preserve issues when the UI needs that location. The tree lookups in the example returned:

text
nested name: ["Too small: expected string to have >=2 characters"]
second score: ["Invalid input: expected number, received string"]

Notice that these lookups end in errors. This property is correct on the tree returned by treeifyError: it contains message strings at that node. It is not the removed alias on ZodError. Replacing it with issues would introduce another bug during the migration.

Use optional chaining when traversing the tree because a branch without a validation error may be absent. Keep the raw path array when you need machine-readable routing. Joining path segments with dots is suitable for display only if your application has defined how it handles field names that themselves contain dots.

When a custom error mapper indexes a typed field table with an arbitrary string, the next compiler failure may be TS7053. Follow the dynamic object-key narrowing fix to make the key match the table's allowed keys. Do not erase the entire issue type to any merely to index a message map.

For a readable diagnostic string, prettifyError is another option. It is useful in logs or an error summary, while issues remain the better source for application logic. The migration guide also marks the older instance format and flatten methods as deprecated; use the documented top-level formatting functions when updating those handlers.

Verify the fix#

Run the failing and corrected minimal files against the same installed dependency versions. For the reproduction, the compiler invocation was:

bash
npx tsc fixed.ts --noEmit --strict --esModuleInterop --module nodenext --target es2022 --pretty false

The failing file exited with a missing-property error; the corrected file exited successfully. Then compile and run the form example, rather than stopping at a clean editor. Confirm that malformed input still takes the failure branch, that the summary retains the unrecognised-key message, and that nested controls receive their own messages.

Finally, submit valid input. The reproduced success response was:

text
valid: {"success":true,"data":{"email":"ada@example.eu","profile":{"name":"Ada"},"scores":[1,2]}}

If the compiler passes but a runtime handler still reads undefined, inspect the executed adapter for an old errors access. Check the property on the actual error object before adding fallback empty arrays: an empty fallback can make a rejected submission appear to have no explanation. A finished migration keeps invalid input rejected, preserves its field and form messages, and allows valid parsed data through the success branch.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote