A mutation with two arguments is easy to write inline. A mutation with twelve, some of them nested objects, is not. GraphQL solves this with input object types, and mature APIs pair them with a payload type for the result. After this lesson you will be able to define input types, pass them as variables, and design mutations that stay readable as they grow.
Object types such as Post describe data flowing out of the server. They cannot be used as arguments, because output types may contain interfaces, unions and fields with arguments, none of which make sense in an input position. The input keyword declares a type that is valid only as an argument or inside another input type:
input CreatePostInput {
title: String!
body: String!
tags: [String!] = []
publishAt: String
}
type Mutation {
createPost(input: CreatePostInput!): Post!
}The rules for input types:
tags: [String!] = []); a field without a default and without ! is optional and absent when not provided.Clients pass the whole object as a single variable, which keeps the operation document stable while the values change:
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
tags
}
}{
"input": {
"title": "Cursor pagination explained",
"body": "Offset pagination breaks when rows are inserted...",
"tags": ["graphql", "pagination"]
}
}Validation checks the variable against the input type before any resolver runs; a missing title or a number where a string is expected is rejected with a message in the errors array. In the resolver, args.input arrives as a plain object with defaults applied:
const resolvers = {
Mutation: {
createPost: async (_parent, { input }, { db, user }) => {
return db.posts.create({ ...input, authorId: user.id });
},
},
};Most public APIs (GitHub, Shopify, Relay-based apps) give every mutation exactly one argument named input. Adding a field never changes the mutation signature, the client passes one variable, and generated TypeScript types map one-to-one to input types. Reserve inline arguments for trivial cases such as deletePost(id: ID!).
Naming follows the mutation: createPost takes CreatePostInput, updatePost takes UpdatePostInput. For updates, make every field optional so clients send only what changed; absent fields arrive as undefined and explicit nulls as null, so the resolver can tell them apart.
Returning the bare Post from createPost works until you need to return something else, such as validation errors or a related object. The payload pattern wraps the result in a dedicated type:
type CreatePostPayload {
post: Post
errors: [UserError!]!
}
type UserError {
field: String
message: String!
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}Expected failures such as "title already taken" are data, not exceptions, so they are returned in errors with post set to null, and the client renders each message next to the matching form control. Unexpected failures (database down) still surface in the top-level errors array.
| Approach | Best for | Drawback |
|---|---|---|
| Return the object directly | Small internal APIs | No place for business errors |
| Payload with errors list | Forms and public APIs | One more type per mutation |
| Union result types | Strongly typed error cases | Clients must use inline fragments |
createPost(post: Post!) fails schema validation.Which of these can be a field of an input object type?
input types are the only object-like arguments; they hold scalars, enums, lists and other input types.$input variable so the operation document stays constant.input argument convention keeps mutation signatures stable as fields are added.errors list return expected failures as data instead of exceptions.Next lesson: Resolvers Explained — see how every field in the schema maps to a function and how arguments, context and the parent value reach it.