Skip to content

Input/output types used the opposite way? (and upgrade blockers) #528

@jaens

Description

@jaens

In the process of upgrading to Zod v4 and a newer version of zod-openapi, I noticed the following issues that blocked the upgrade:

Input vs. output mode for objects

For requests and responses, the way JSON schema generation handles "input" vs. "output" seems inverted compared to good API engineering practices. Let me try to explain.

The current implementation

The Zod JSON schema generator directly reflects the semantics of Zod validation. In other words, when setting io to input, the generated type is exactly what Zod can accept as input, and when set to output, it's exactly what type Zod would produce after parsing.

For example, consider z.object({ aField: z.string() }).

  • The input mode produces a schema that allows any object with aField, plus any additional fields (which are simply ignored).
  • The output mode produces a schema that allows only aField, with no additional properties.

From the perspective of an API server using Zod, this accurately describes the behavior, so it seems logical and fine.

The consequences

Unfortunately, for API clients, this has serious engineering drawbacks. When generating, for example, TypeScript equivalents of these schemas:

  1. For requests: There are no warnings when the client passes extra (unknown) parameters, since the schema allows them.
    This is almost certainly actually a critical bug - extra parameters mean the client expects the server to handle something it definitely will not.

  2. For responses: The client will not expect any extra fields.
    But upgrading APIs typically involves exactly that: adding extra fields to responses. Older clients would ignore them, while newer ones can make use of them. Therefore:

    1. The client actually validating the schema would make it impossible to upgrade APIs in a backward-compatible way, since any new field breaks validation.
    2. (Minor point) For languages other than TypeScript, such as Java, preserving unknown properties requires explicit (auto-generated) extra code. Not allowing this in the schema makes it impossible to transparently pass (proxy) domain objects between subsystems without losing those fields.

(I've seen firsthand millions of dollars going down the drain due to failing to grasp the above two principles, but uh, that's a story for another time...)

I'm not going to get into the subtleties of z.looseObject vs. z.strictObject, but anyway, they don't help when the same domain object/schema is used in both request and response contexts.

Suggestion: Switch the request/response <-> input/output modes for generating JSON schemas.
(the actual fix might be more involved in case the mode affects more than objects)

Output suffix for output schemas (vs. input)

Currently, when the same type is used in both contexts, the conflict is resolved by adding a suffix to the output type (...Output).
Of course, the same could be achieved by adding a suffix to the input type instead.

I would guess that in most applications, input/request types are used only a handful of times (for create/update/delete operations), since domain objects are usually created in only a few places with bespoke code. Because of eg. field optionality, these inputs represent more of a "template" than the actual domain object.

The response/output type, on the other hand, often directly represents the domain object. In the vast majority of code I've seen, these types are referenced many times as they flow through layers, modules, and views.

From that perspective, making the output type name "unnatural" tends to increase verbosity and decrease readability. You end up with domain objects named SomeTypeOutput instead of just SomeType, which gets confusing in code that isn't directly adjacent to API request handling.

Suggestion: Allow suffixes on input types instead.

Backward incompatibility

The real showstopper is that for existing Zod v3 schemas with generated OpenAPI clients for languages with nominal types, adding the Output suffix to response types breaks any current code using them. Since the type name is part of the public interface and widely referenced, the entire codebase would have to be refactored.

Suggestion: Provide a switch to retain the old behavior (ie. the same type for input and output).


(I'm willing to contribute PRs for these issues if there's consensus on how to move forward 😅)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions