The Same Contract in Two Directions
Zod’s two-sided model matters whenever a value crosses a representation boundary: a form sends strings, an API carries JSON, and application code wants richer domain values. The focus here is how Zod distinguishes accepting, supplying, mutating, and converting a value. That distinction lets a developer design a string-to-Date boundary, handle environment booleans deliberately, and know when a value can be encoded back—or when it cannot.
Not every change to input means the same thing
Several Zod features can make an inconvenient input easier to use, but they do so under different contracts. Treating them as interchangeable is a reliable way to hide a boundary bug.
The most direct example is primitive coercion. z.coerce.string() passes its input through JavaScript’s String() constructor; the number and boolean variants use the corresponding constructors. That means the behavior is JavaScript behavior, not a product-specific interpretation. A number can become a string, and any truthy value becomes true under z.coerce.boolean(). The string "false" is truthy, so it becomes true, which is often surprising when the value came from an environment variable or form field.
That surprise is not a defect in the coercion API. It is a mismatch between two meanings of “convert this to a boolean.” If the application means JavaScript truthiness, coercion is explicit and useful. If it means that words such as "true", "yes", or "enabled" represent true and words such as "false", "no", or "disabled" represent false, z.stringbool() expresses that narrower protocol. Its accepted words can be customized, and the schema is case-insensitive by default. The product meaning belongs in the schema rather than in an assumption hidden after parsing.
Defaults and prefaults solve a different problem: absence. A default short-circuits when the input is undefined and returns the default value directly. Because the inner parser does not process that value in the forward direction, the default must match the schema’s output type. A prefault substitutes a value on the input side and continues through the schema.
const direct = z.string()
.transform((value) => value.length)
.default(0);
direct.parse(undefined); // 0
const processed = z.string()
.transform((value) => value.length)
.prefault("tuna");
processed.parse(undefined); // 4
The difference is observable. The default says, “when nothing was supplied, the output is already this value.” The prefault says, “when nothing was supplied, pretend this input arrived and run the normal pipeline.” Trimming, checks, and transformations therefore still apply to the prefault. The input/output types reflect the choice: the input may include undefined, while the output can be a defined number.
.catch() addresses failure rather than absence. It runs the inner schema and returns a fallback when that parse fails. This can be appropriate for a non-critical preference where a safe fallback is genuinely the intended behavior. It can also conceal a broken producer if used around a required identifier, permission, or financial value. The schema will make the failure disappear from the caller’s control flow, so the choice should be treated as a product policy, not a generic error-handling shortcut.
The mechanisms above can be summarized as different transitions:
- Coercion changes the representation according to JavaScript’s constructor rules.
- Default supplies an output without sending it through the forward parser.
- Prefault supplies an input and lets the ordinary parser process it.
- Catch replaces a failed result with a fallback.
Each can be useful, but each changes what evidence the caller receives.