When “Valid” Needs an Explanation

Zod becomes more useful once a value has passed a basic type check but still may violate a rule that TypeScript cannot express. The focus here is the layer of checks and errors that turns “this input is not acceptable” into a precise account of what happened. That distinction helps a developer add domain rules, preserve the right amount of diagnostic information, and choose between a developer-facing error, a form response, and a cheap validity test.

Rules beyond a primitive type

A primitive schema can establish that a value is a string or a number. Real boundaries usually need more. A username may have a length limit. A number may need to be positive. Two fields in a password form may need to agree. An array may need to contain no duplicates. These are constraints on a value that are not captured by its broad TypeScript type.

Zod calls this layer checks or refinements. A built-in check expresses a common constraint, while .refine() accepts a predicate for a rule specific to the application:

const Password = z.string()
  .min(8, { error: "Use at least eight characters" })
  .refine((value) => value !== value.toLowerCase(), {
    error: "Include an uppercase letter",
  });

The base parser establishes that the input is a string before the refinement receives it. The refinement then asks a narrower question about that string. If it passes, the output is still a string. The check has made the accepted set smaller; it has not changed the schema’s inferred type to a new runtime type.

That distinction matters when a check performs same-type normalization. Zod’s trim, case conversion, and .overwrite() behavior can change the value while keeping its inferred type. A trimmed string remains a string. This is different from a transformation that turns a string into its length, a date, or some other output. A same-type overwrite remains part of the original schema’s type contract; a transformation creates a new input/output relationship and cannot be treated as merely another predicate.

Zod 4 stores refinements inside schemas as checks rather than putting every refinement behind the older wrapper architecture. The practical consequence is that checks can be interleaved with other schema operations. A developer can describe a string, add a custom rule, and continue with compatible string behavior instead of losing access to the base schema’s operations behind an opaque effect wrapper.

The lower-level .check() API gives a callback direct access to the issue list and can create several issues in one operation. It is more verbose, and the documentation presents it as a lower-level choice that may help in performance-sensitive code. For most application rules, .refine() is easier to read. The difference is not that one is “real validation” and the other is not; both add checks to the same execution model.

The location of a rule should match the value it needs to inspect. A minimum length belongs on a string because it depends on one string. “Password and confirmation must match” belongs on the object because it depends on two fields. Zod lets that object-level refinement attach its issue to a particular path, such as confirm, so the interface can show the problem next to the field the user can change. The runtime rule remains relational even though the presentation points to one property.

For more involved checks, the lower-level API can add several issues at once—for example, an array could be too long and also contain duplicates. That is useful when the caller needs a complete account of one input, but it does not mean every possible check should run unconditionally. A check that assumes a valid string should not execute after the value failed to become a string, and a check that performs expensive external work may be better made aborting or asynchronous. The design question is always which evidence a later rule requires and whether the cost of obtaining it is justified.

The check pipeline has stopping conditions

Checks are not all executed in an undefined order. Zod runs them after the relevant value has been parsed, in the order in which they are attached. By default, a failed check is continuable: Zod records its issue and proceeds to later checks when it can. That default favors completeness. A caller validating a form can learn about several problems in one pass instead of correcting one field only to discover the next problem on a second submission.

A check can be marked abort. When that check fails, later checks do not run. This is useful when a later predicate depends on a precondition established by an earlier one, or when continuing would perform needless work. The choice has a trade-off: aborting may reduce cost and avoid unsafe downstream assumptions, but it also reduces the amount of feedback returned for the same input.

Zod also supports a when condition for cases where a check depends on only part of a larger object. Ordinarily, an earlier non-continuable issue can prevent a later refinement from running because Zod cannot assume that the refinement will receive a value of the expected shape. when allows a developer to define a narrower condition for running the check—for example, validate that two password fields match if those two fields are valid even when an unrelated field has an error.

That control is powerful because it changes the dependency graph of validation. It should not be used as a way to force every callback to run. The condition must be written so the callback’s assumptions remain true. A refinement that reads data.password as a string while the password field is itself invalid has moved the risk from a visible validation issue into arbitrary user code.

Asynchronous work creates another explicit boundary. A refinement or check that returns a promise may perform an external lookup, such as checking whether an identifier exists. The caller must use parseAsync, safeParseAsync, or validateAsync as appropriate. Calling the synchronous operation does not wait for the promise or interpret a promise as a successful value; Zod reports that asynchronous work was encountered. This prevents a timing error from becoming a false validation result.

stateDiagram-v2 [*] --> TypeParsed TypeParsed --> Checks: base type accepted TypeParsed --> Failed: type issue Checks --> Checks: continuable issue Checks --> Failed: aborting issue Checks --> Async: promise returned Async --> Checks: resolved check Checks --> Output: no blocking issues Failed --> [*] Output --> [*]

The stopping rules explain why a refinement should return a falsy result or add an issue rather than throw. A falsy result participates in Zod’s failure model: it can be attached to the relevant schema and reported with the other issues. A thrown exception is different. Zod does not reinterpret arbitrary exceptions from refinement functions as validation failures, so the exception escapes as a programming or operational error. Custom logic remains user code, with user-code responsibilities for handling unexpected conditions.

An issue is data before it is prose

When parsing fails, Zod’s primary product is not a sentence. It is an issues array. Each issue has a code and a path, plus metadata appropriate to the failure. An invalid type, a value that is too small, a bad string format, an unrecognized key, and a custom rule can therefore be distinguished by program logic before anyone chooses how to display them.

Consider a strict object containing a username and favorite numbers. If the username is a number, the second array element is a string, and the input includes an extra key, one parse can report three separate issues. Their paths identify the username, the second array position, and the object-level extra key. The caller can use that structure to attach feedback to the right field, count categories of failures, or decide whether the problem is a client correction or a contract mismatch.

The code is the stable discriminator; the message is a presentation result. Zod’s issue formats include metadata such as an expected type, a minimum or maximum, a failed format, or the set of unrecognized keys. A locale or custom error map can turn that metadata into different language without changing the underlying fact. Application code that branches on message text is coupled to wording that may change with configuration or version.

This structure also preserves nested failure information. A union can carry the issues produced by its alternatives. A record or map can identify whether a key or an element failed. An object path can be rendered as a sequence of properties and array positions. That is why a detailed error does not have to be a wall of text: the application can select the level of detail appropriate to its consumer.

The distinction between a type failure and a domain failure is useful when operating the boundary. “Expected string, received number” points to a producer or contract mismatch. “Too short” points to a value that has the right broad type but violates a narrower requirement. “Unrecognized key” may indicate a harmless extension, a client/server version skew, or a misspelled setting, depending on the object policy. The issue metadata gives the surrounding system enough context to make that judgment without treating every rejection as the same incident.

A union makes the need for structure even clearer. If several alternatives fail, the nested issues explain how each candidate was rejected. A caller can preserve that detail for a developer while presenting a shorter message to an end user. If the application instead keeps only a boolean, it has chosen a cheaper boundary but has also discarded the evidence needed to diagnose the branch.

The same issues can serve several consumers

Zod provides multiple ways to turn issues into useful output. z.treeifyError() creates a nested structure that mirrors object properties and array items. It is a good fit when a form or interface needs to walk back to a particular field. z.flattenError() is more convenient for a shallow object, separating top-level form errors from field errors. z.prettifyError() produces a human-readable multiline string with paths such as favoriteNumbers[1], which is useful for a log, a command-line tool, or a concise response.

These utilities do not change the underlying validation. They are projections of the same issue data. A frontend can use a tree, an API adapter can map field errors into its response format, and an operator can read a pretty string. Keeping those projections downstream of the parser avoids baking one presentation format into the schema itself.

For a form, this separation allows the validation boundary to remain independent of the interface. The schema can report that contact.email has an invalid format and that items[1] is the wrong type. A web layer can select the first message for each visible field, while a service layer can retain the complete issue set for a response or metric. Neither layer needs to rewrite the rule or infer a location from English prose. The path is structural, and the message is replaceable.

Error customization follows the same separation. Zod 4 uses one error parameter for fixed messages and error-map functions. The function receives the issue and can inspect its code, input when available, path, originating check, and constraint metadata. It can return a custom message or return undefined to yield to the next map in the precedence chain.

The precedence order is deliberately explicit. A message attached to the individual check has the highest priority, followed by a schema-level message, a per-parse error map, a global custom error map, and finally the configured locale. This lets a library establish a default language while a particular constraint or field can provide a more specific explanation. It also means a per-request map cannot casually override a message that the schema author made more specific.

The timing of an error map has an important consequence in Zod 4.6. For safeParse, the error object is constructed lazily when result.error is first read. If the caller checks only result.success, it can avoid the cost of building an error that it will never inspect. But a side-effectful error map may run at that first read, and it observes the configuration active at that moment. Throwing .parse() is different: it constructs and throws the error immediately. Error maps should therefore be treated as formatting functions, not as a hidden place to perform required business actions.

A useful diagnostic is not automatically a safe log

Zod deliberately does not include the original input in issue objects by default. Values arriving at a boundary may contain passwords, tokens, personal data, or other secrets, and a diagnostic object is easy to send to a log or monitoring system. A caller can request input reporting with reportInput: true, but that is an explicit decision to accept the exposure risk. Formatting an issue later does not automatically redact a value that a custom message or an opted-in issue has already included.

The same caution applies to schema-level descriptions and custom error maps. A message such as “account number is invalid” may be safe to show to a user, while a message that echoes the rejected value may not be. A localized error is still an error about potentially sensitive data. The choice of error surface belongs to the boundary owner, not only to the validator author.

The result is a disciplined division of responsibility. Zod can establish that a value violates a declared rule, preserve where the violation occurred, and give different consumers a suitable projection. The application decides whether the failure becomes a retry, a form response, a metric, a log entry, or an incident. It also decides which checks should continue, which should abort, and which custom callbacks are safe to run. “Valid” becomes useful only when the explanation and its handling are designed with the same care as the predicate.

References