Input Validation
Validation in GraphQL happens in two distinct layers, and confusing them is a common source of bad APIs. The first layer is structural: the schema itself enforces types, required arguments, and enum membership before a resolver ever runs. The second is semantic: the business rules — “the email must be unique,” “the rating is between 1 and 5” — that only your code knows. The schema handles the first for free. The second is your job.
What the schema validates for free
Section titled “What the schema validates for free”Declare an input and the engine guards it for you. If an argument is non-null, omitting it is a request error. If it expects an Int, sending a string is a request error. If it is an enum, a value outside the enum is rejected. All of this is caught during the validation phase, before execution — so these failures come back as request errors with no data, exactly as we saw two lessons ago.
input ReviewInput { trackId: ID! rating: Int! comment: String}
type Mutation { addReview(input: ReviewInput!): Review}Given this schema, you cannot call addReview without an input, cannot omit trackId or rating, and cannot send a rating that is not an integer. The type system rejects all of that without a line of resolver code. What it cannot know is that rating must be between 1 and 5, or that comment must be under 500 characters — those are business rules.
Two ways to report a business-rule failure
Section titled “Two ways to report a business-rule failure”When a business rule fails in your resolver, you have a design choice, and both approaches are legitimate.
flowchart TD Input["Incoming arguments"] Schema["Schema validation: types, required, enums"] ReqErr["Fails structure: request error, no data"] Resolver["Resolver: business rules"] Throw["Unrecoverable: throw GraphQLError -> top-level errors"] Payload["Recoverable: return userErrors in payload -> normal data"] Input --> Schema Schema -->|"invalid shape"| ReqErr Schema -->|"valid shape"| Resolver Resolver -->|"system fault"| Throw Resolver -->|"input mistake"| Payload
Throw. Throw a GraphQLError with a code like BAD_USER_INPUT. The failure lands in the top-level errors array, data for that field is null. This is simple and uniform, but it mixes expected, recoverable input mistakes in with genuine system errors, and a client must dig through errors to find them.
Return userErrors in the payload. Model the mutation’s return type as a payload that contains both the result and a list of typed user errors. A failed validation is then ordinary data — not an exception — so the client reads data.addReview.userErrors like any other field. This is the pattern many large schemas adopt because recoverable form-style errors are first-class, strongly typed, and never tangled up with the errors channel.
type UserError { field: String message: String!}
type AddReviewPayload { review: Review userErrors: [UserError!]!}Rule of thumb: throw for failures the user cannot fix (a malformed request, an internal fault), and return userErrors for the recoverable mistakes you expect a form to surface back to a person.
Validating input, run for real
Section titled “Validating input, run for real”The runner below uses the payload pattern. The resolver validates rating against the 1–5 business rule and returns either the created review or a populated userErrors list — all as normal data, never an exception. Press Run with an out-of-range rating and watch the userError come back inside the payload.
Notice what is missing from the output: there is no top-level errors array. The rating of 9 broke a business rule, but because we modeled it as a userErrors field, the failure arrives as ordinary data — data.addReview.review is null and data.addReview.userErrors holds a typed entry pointing at the rating field. The client treats it like any other selection. Try changing rating to 4 and rerunning: review fills in and userErrors comes back empty.