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:
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-endedextensionsmap.
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
... onfragments 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_FOUNDso 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¶
- sources/2021-04-12-zalando-modeling-errors-in-graphql —
the naming and worked
registerexample is from here.