Skip to content

The Input / Payload Pattern

The mutations in the last lesson took loose arguments and returned a bare object. That is fine for two fields. But real mutations grow: you add an optional field, then another, then a validation rule that can fail for three different reasons. Two conventions keep that growth under control — a single input argument going in, and a structured payload type coming out. Together they are the most widely adopted shape in production GraphQL.

Instead of spreading every field across the argument list, define one input object type and accept it as a single argument named input:

input CreateReviewInput {
trackId: ID!
rating: Int!
body: String
}
type Mutation {
createReview(input: CreateReviewInput!): CreateReviewPayload!
}

An input type looks like an object type but uses the input keyword, and its fields may only be scalars, enums, or other input types — never object types. The payoff is evolution: adding an optional field to CreateReviewInput does not change the mutation’s signature, so existing clients keep working untouched. It also gives the whole set of inputs a name you can reference, document, and reuse.

A bare Review! return type has no room for anything except the review. But mutations need to report two things: the entity that was created or changed, and whether anything went wrong in a way the client can act on. The payload type carries both:

type UserError {
message: String!
field: [String!]
}
type CreateReviewPayload {
review: Review
userErrors: [UserError!]!
}

Two design choices matter here. review is nullable — on failure there is nothing to return, so it comes back null. And userErrors is a non-null list of non-null errors — always present, just empty ([]) when everything succeeded. The client’s logic becomes uniform: check userErrors first; if it is empty, trust review.

flowchart LR
  In["CreateReviewInput!"] --> Mut["createReview"]
  Mut --> Valid{"valid?"}
  Valid -->|"yes"| OK["payload: review set, userErrors []"]
  Valid -->|"no"| Err["payload: review null, userErrors [...]"]
One input flows in; the payload carries either the entity or the user errors back out.

These userErrors are expected, recoverable problems — a rating out of range, a missing title — the kind a user can fix and retry. They live in the payload, as data, so the client can render them next to the offending form field. That is different from protocol-level errors (a malformed query, an auth failure), which belong in the top-level errors array. The next lesson on best practices draws that line in detail; for now the rule is: business validation goes in the payload.

The example implements createReview(input: CreateReviewInput!): CreateReviewPayload! with real validation. Press Run: the first call sends an invalid rating and gets back a populated userErrors with a null review; the second sends valid data and gets the review with an empty userErrors.

JavaScript

Read both results side by side. The invalid call returns review: null and a userErrors entry pointing at the rating field — and crucially, the top-level errors array is absent, because nothing went wrong with the request; the user simply sent bad data. The valid call returns the review and an empty userErrors: []. A client can branch on a single rule — “is userErrors empty?” — regardless of which mutation it called.

What is the main advantage of accepting a single input object instead of many separate arguments?
In the payload pattern, why is the entity field (e.g. review) nullable while userErrors is a non-null list?
Which kind of problem belongs in the payload’s userErrors rather than the top-level errors array?