Skip to content

PATTERN Cited by 1 source

Result union type for mutation outcome

Pattern

Model a mutation's return type as a GraphQL union of a Success type and a Problem type, so the Success and the failure payload are equal first-class members of the schema:

type Mutation {
  register(email: String!, password: String!): RegisterResult
}

union RegisterResult = RegisterSuccess | RegisterProblem

type RegisterSuccess {
  id: ID!
  email: String!
}

type RegisterProblem {
  "translated message encompassing all invalid inputs."
  title: String!
  invalidInputs: [RegisterInvalidInput]
}

At the call site, the client spreads both arms of the union:

mutation {
  register(email: $e, password: $p) {
    ... on RegisterSuccess { id  email }
    ... on RegisterProblem { title  invalidInputs { field  message } }
  }
}

The pattern is borrowed from the Result / Either type familiar from Rust, Haskell, and Scala, and adapted to GraphQL's union mechanism.

Origin

Named and recommended in Zalando's 2021 GraphQL errors post as the canonical shape for mutations whose failure modes are Customer-actionable (Source: sources/2021-04-12-zalando-modeling-errors-in-graphql).

The naming convention ships with the pattern: - Return type is named <Operation>Result. - Union members are <Operation>Success and <Operation>Problem — the word Problem is chosen deliberately over Error because the GraphQL spec has already claimed the name error for the response envelope (see concepts/problem-vs-error-distinction).

Why a union over nullable fields

A naive alternative is to make every mutation return a nullable Success and let errors propagate into response.errors:

register(email: String!, password: String!): RegisterSuccess

On validation failure the field becomes null and the error surfaces in response.errors. This works for developer-actionable failures but fails for customer-actionable ones because:

  • Translated, per-field customer-facing messages don't have a typed home.
  • Clients have no codegen for the error payload (the schema discoverability gap).
  • Per-field structure ({ field: EMAIL, message: "Invalid" }) is buried in an open-ended extensions map.

Moving the failure into a union arm makes all of those first-class schema citizens.

Constraints and trade-offs

  • No error-propagation semantics. The Problem arm is a branch of the union, not an error. It does not bubble up to the nearest nullable field. This is the feature — the client gets rich data — but it's also a semantic divergence from Error-path failures.
  • Requires fragment spreads everywhere. Every call site must spread both arms. For mutations this is tolerable because each mutation has one call site per UI action. For queries traversed at depth, it becomes the anti-pattern Zalando warns about:

    "This way of handling errors at every level is not friendly for front-end developers. It's too much to type in a query and too many branches to handle in the code."

  • Tooling support varies. Apollo, Relay, and graphql-codegen all support ... on fragments cleanly, but the developer ergonomics differ per client.
  • Cannot be used for "query-shape" failures. Errors that need to propagate up a deeply nested query tree (a 404 on a collection child) stay in response.errors. See patterns/error-extensions-code-for-developer-actionable-errors.

When to use

  • Any mutation where the failure is Customer-actionable — input validation, business- rule violation the user can fix, etc. This is Zalando's named "only crucial use-case" for the pattern.
  • When translated customer-facing messages or per-field structured error data need a schema-defined home.

When not to use

  • For 404 / Not Found on queries — use an Error with extensions.code = NOT_FOUND so null propagation handles the UX.
  • For authorization failures — use extensions.code = NOT_AUTHORIZED.
  • For internal / runtime exceptions — use an unclassified Error and let the front-end retry/error-page.
  • For read queries unless the caller explicitly wants to branch — the pattern's cost scales with nesting.

Seen in

Last updated · 476 distilled / 1,218 read