Skip to content
intermediate Phase 3 · Mutations, Arguments, and Input Types

Input Object Types

Grouping mutation parameters into reusable input types with clear validation rules.

40m
2 problems
Topic Progress 0%

Twelve Flat Arguments Signal the Wrong Shape

Twelve Flat Arguments Signal the Wrong Shape

The first version of a create mutation usually looks reasonable:

createProduct(
  name: String!
  sku: String!
  priceCents: Int!
  currency: String!
  weightGrams: Int
  categorySlug: String!
  tags: [String!]
): Product!

Then marketing asks for a description, brand, dimensions, a hazmat flag, a tax code, and a landing-page slug. The signature becomes unreadable, every call site turns into keyword soup, and adding an optional argument later means touching every wrapper around the API. Grouping the data into one input object restores a nameable concept:

type Mutation {
  createProduct(input: CreateProductInput!): Product!
}

input CreateProductInput {
  name: String!
  sku: String!
  priceCents: Int!            # minor units avoid floating-point money
  currency: String! = "USD"
  weightGrams: Int
  categorySlug: String!
  tags: [String!] = []
  hazmat: Boolean! = false
}

What changed beyond readability:

  • The mutation grew one argument instead of ten. Future fields join the input type; existing client documents keep compiling.
  • Inputs compose. The same type feeds a bulk import — bulkImport(inputs: [CreateProductInput!]!) — and an admin variant, with no field redeclaration.
  • Defaults sit with the data. currency: String! = "USD" documents the fallback where the field is defined instead of inside three resolver branches.

Input objects may also be recursive: a CommentInput can carry replies: [CommentInput!] for a nested comment tree, because input types describe data, not a graph of resolvers. The one rule a recursive input must obey appears in the next chapter — a cycle built only from non-null fields has no terminating value, so schema validation rejects it.

What an Input Type Is Allowed to Contain

What an Input Type Is Allowed to Contain

Output types (type Post) and input types (input CreatePostInput) look alike but obey different rules, and the difference is structural rather than stylistic.

type (output) input (input)
Field value kinds scalars, enums, objects, unions, interfaces scalars, enums, input objects
Resolvers on fields yes never
Arguments on fields yes never
Used as field return types argument and variable types

A field on an output type can be a function: Post.author may hit a database, an HTTP service, or a derivation. An input field is plain data arriving from the client — there is nothing to resolve — so allowing output object types there would be meaningless, and the spec forbids it. This is why a client cannot pass a Post into createProduct: it declares a parallel input holding the subset of fields a caller may set.

The naming convention follows from that parallel: input types end in Input — CreatePostInput, UpdatePostInput, ProductFilterInput. Without the suffix, Post would mean two different shapes depending on direction, and generated clients would collide.

Nullability inside the input carries update semantics:

input UpdatePostInput {
  title: String           # omitted -> leave alone, null -> clear
  tags: [String!]         # omitted -> leave alone, [] -> remove all tags
}

A partial update declares every field nullable and lets absence mean "unchanged." The pattern only works because an omitted field and an explicit null arrive differently; field arguments covers that coercion rule, and the payload side of reporting rejected fields is in mutation return payloads.

Two constraints worth knowing before an interview:

  1. No interfaces or unions on input fields. Inputs must be concrete data shapes; polymorphism belongs on the output side, where a resolver can choose a concrete type.
  2. Non-null cycles are rejected. input Node { child: Node! } has no finite value — building it would require an infinitely deep object — so schema validation refuses it, while input Node { child: Node } is legal because the chain can bottom out at null.

Validation the SDL Cannot Express

Validation the SDL Cannot Express

Declaring priceCents: Int! guarantees a number arrives. It says nothing about whether the number is sane. SDL expresses shape — types, nullability, lists, defaults, enums — and stops there. Business rules are code that runs before the write:

function validateCreateProduct(input) {
  const errors = [];
  if (input.priceCents < 0)
    errors.push({ field: 'priceCents', message: 'must be >= 0' });
  if (!/^[a-z0-9-]{3,32}$/.test(input.sku))
    errors.push({ field: 'sku', message: 'lowercase letters, digits, dashes, 3-32 chars' });
  if (input.tags.length > 20)
    errors.push({ field: 'tags', message: 'at most 20 tags' });
  return errors;   // reported in the payload, not thrown
}

Three homes for this logic, each with a real tradeoff:

  1. Inside the resolver — simplest, one execution path, but rules spread across mutation handlers and get re-typed for createProduct and bulkImport.
  2. A shared validator module the resolvers call — one source of truth, unit-testable without spinning up a GraphQL server. This is the default recommendation: the schema stays declarative and the rules stay plain functions.
  3. Schema directives such as @constraint(min: 0) — visible through introspection and enforced uniformly, at the cost of implementing the directive and teaching clients another convention.

Batch inputs change failure semantics, so decide before shipping:

bulkImport(inputs: [CreateProductInput!]!): BulkImportPayload!

With thirty inputs, does one bad row reject all thirty, or do twenty-nine land? SDL cannot answer that for you. Either run the batch in a transaction and fail atomically, or return a payload with per-index results and errors as described in mutation return payloads. The dangerous option is not deciding: a half-imported catalog plus a generic error leaves clients unable to say what actually happened.

Interview angle: ask what happens to the mutation when a field fails validation — where the error is reported, whether earlier writes committed, and how the client learns which input was rejected. Those three questions separate candidates who have shipped input-heavy mutations from those who have only read the syntax.

Practice Problems

0 / 2 solved
Refactor Flat Mutation Arguments Into an Input Type
Input Type Design

A createArticle mutation has grown to nine flat arguments. Regroup them into an input object so the mutation takes a single input argument, keep every currently-required field required, preserve the existing default for comments, and make the same input reusable by a bulk variant.

Example:

Input: mutation { createArticle(input: { title: "Resolvers", body: "...", tags: ["graphql"] }) { id } }

Output: { "data": { "createArticle": { "id": "a-1" } } }

The client sends one object; required fields inside it are enforced the same way required top-level arguments were.

Optimal Solution — O(1) time, O(1) space

Move each flat argument into an input object, mark required ones non-null, carry defaults onto the input fields, and point the mutation at the input.

input CreateArticleInput {
  title: String!
  body: String!
  authorId: ID!
  tags: [String!] = []
  commentsEnabled: Boolean! = true
  canonicalUrl: String
  locale: String! = "en"
  heroImageId: ID
  seriesSlug: String
}

type Mutation {
  createArticle(input: CreateArticleInput!): Article!
  bulkImport(inputs: [CreateArticleInput!]!): BulkImportPayload!
}

Edge Cases:

  • A previously optional argument must not become non-null during the move — that breaks existing callers
  • Defaults migrate onto input fields; forgetting them silently changes behavior for omitted fields
  • Renaming the mutation argument (not just regrouping) breaks every existing document
Define Bulk Input With Per-Item Failure Reporting
Input Type Design

Design the input and payload types for bulkImport, which accepts up to 100 products. One invalid row must not be reported as a mystery: the client needs to know which indexes failed and why, while valid rows should be able to commit. Show the SDL and explain where the per-item checks run.

Example:

Input: mutation { bulkImport(inputs: [{ name: "Lamp", priceCents: -1 }, { name: "Desk", priceCents: 4500 }]) { importedCount results { index status } } }

Output: { "data": { "bulkImport": { "importedCount": 1, "results": [ { "index": 0, "status": "FAILED" }, { "index": 1, "status": "IMPORTED" } ] } } }

Indexes line up with the input array, so the client can repair row 0 and resubmit without touching row 1.

Optimal Solution — O(n) time, O(n) space

Type the batch as a list of inputs, validate each item with the shared validator, and return index-addressed results in a payload instead of throwing on the first bad row.

type BulkImportResult {
  index: Int!
  status: ImportStatus!
  id: ID
  errors: [FieldError!]
}

enum ImportStatus { IMPORTED REJECTED SKIPPED }

type BulkImportPayload {
  importedCount: Int!
  results: [BulkImportResult!]!
}

type Mutation {
  bulkImport(inputs: [CreateProductInput!]!): BulkImportPayload!
}

// resolver sketch
const results = await Promise.all(
  inputs.map(async (input, index) => {
    const errs = validateCreateProduct(input);
    if (errs.length) return { index, status: 'REJECTED', errors: errs };
    const product = await ctx.dataSources.products.create(input);
    return { index, status: 'IMPORTED', id: product.id };
  })
);

Edge Cases:

  • An empty inputs array should be rejected by the resolver even though the list type allows it
  • Duplicate skus across the batch need batch-level validation, not per-row checks
  • Partial success semantics must be documented: imported rows stay imported when others fail

Quiz

1. Which type may be used as the type of an input object field?

Question 1 options

2. What does grouping ten flat arguments into input: CreateProductInput! actually buy you?

Question 2 options

3. Which input definition does schema validation reject, and why?

Question 3 options

4. Where must the rule "priceCents must be >= 0" be enforced?

Question 4 options

Flashcards

Question

What input object types are for

Answer

Grouping related mutation or filter arguments into one reusable, nameable object. The mutation gains one argument instead of a growing flat list.

Question

Input types vs output types

Answer

input fields: scalars, enums, input objects — no resolvers, no arguments, no interfaces/unions. type fields: anything resolvable, including objects and unions.

Question

Why inputs end in Input

Answer

CreatePostInput and Post are different shapes in different directions; the suffix keeps generated client names and reader expectations unambiguous.

Question

Partial update pattern

Answer

All fields nullable with no defaults: omitted = leave unchanged, explicit null = clear the value. Defaults would mask omission.

Question

Where business validation lives

Answer

Resolver or a shared validator module (or an @constraint-style directive). SDL only expresses types, nullability, lists, and defaults.

Revision Notes

Key Takeaways

  • 1. Input objects exist so mutations grow one stable argument instead of an expanding flat list
  • 2. Input fields accept only scalars, enums, and other input objects — output types with resolvers are forbidden
  • 3. A non-null circular input has no finite value and is rejected at schema build time
  • 4. Shape is validated by the schema; ranges, formats, and permissions are code you write at the resolver boundary

Interview Tips

  • • When asked to "clean up this mutation" on a whiteboard, reach for an input type and explain the versioning benefit, not just the aesthetics
  • • Be ready to explain partial updates: why update fields are nullable without defaults, and how omitted differs from explicit null

Cheat Sheet

Input Object Types Cheat Sheet

input CreateProductInput {
  name: String!
  priceCents: Int!
  currency: String! = "USD"
  tags: [String!] = []
}

type Mutation {
  createProduct(input: CreateProductInput!): Product!
  bulkImport(inputs: [CreateProductInput!]!): BulkImportPayload!
}
  • Input fields: scalars, enums, input objects (+ lists/non-null) — never object types, unions, interfaces, resolvers, or field arguments
  • Name inputs with the Input suffix; inputs may be recursive unless the cycle is all non-null
  • Partial update: nullable fields, no defaults → omitted = unchanged, null = clear
  • Defaults fill omission only; explicit null overrides them
  • Business rules (ranges, formats, uniqueness) = resolver/validator code, never SDL
  • Batch inputs: decide atomic (transaction) vs per-item (errors in the payload) before shipping

Continue Learning