25 April, 20245 minute read

Why does GraphQL have separate input and output types?

There are a few languages out there which come with multiple “types” of object, but GraphQL may just have the most interesting implementation of this idea. The only difference between a struct and a class in C++ is the default access level of their members, but in GraphQL there are significant semantic differences between objects defined using the type and input keywords. This aspect of the type system can be surprising for newcomers to the tech.

Object types defined using the input keyword may only be used to type field arguments, while type objects are used everywhere else in your schema. In this article, an “input object type” will mean an object defined with input while an “output object type” will mean an object defined with type.

Why does GraphQL have two different kinds of objects, complicating what is an otherwise simple type system?

Input and output objects break differently

…and that’s really all there is to it. GraphQL has a hard split between input and output type definitions because adding and removing fields causes different kinds of breaking changes in each circumstance. The number one guiding principle for GraphQL’s design is to make it as easy as possible for forward evolution to occur, and one of the most powerful tools available to the RFC authors for enabling this is to design the language in a way that makes introducing breaking changes to a GraphQL schema more difficult.

In fact, there are only two situations where type changes behave identically in each kind of object type:

But in every single other situation, input and output objects have divergent behavior. Let’s go through each possibility.

Adding a non-nullable field

This always breaks consumers when the non-nullable field is added to an input type. GraphQL doesn’t allow consumers to provide unknown input arguments to resolvers—there is no equivalent to OpenAPI’s additionalProperties field—which means it is impossible for a query document to pass type checks both before and after the addition of a non-null field on an input type.

On the other hand, consumers of an output type are fine. GraphQL requires that consumers explicitly request all fields they want to be returned from the server, so adding a new field to an output type is basically an invisible change as far as existing consumers are concerned.

Changing a non-nullable field to nullable

Here input types are fine; relaxing the non-null constraint is effectively just “widening” the type. All values being provided by consumers today already passed type checks with the stricter non-null type, so they will continue to type check under the more forgiving nullable type.

Output types are a different story. At the schema level things look OK, and query documents that are valid today will still be valid after we deploy this schema change. But in this situation we are vulnerable to a different failure mode: consumers written to target the old schema have likely not been written in a null-safe manner, because they didn’t need to handle null values. Now that it is legitimate for our server to return nulls, we risk the consumer dereferencing a null pointer and running into trouble that way.

Making a nullable field required

This is similar to the case of adding a non-nullable field: input type consumers can break, while output type consumers are fine.

The difference is in the details. Where adding a new non-nullable field to an input type will always break consumers, tightening up a nullable field only usually breaks consumers. Cases where the consumer is already passing non-null values and where operation variable definitions were already being typed by the consumer as non-null will continue to typecheck and work as expected. Where one—or both—of these prerequisites do not hold, things will break.

Output types are fine; they will have been written to include null checks, and are now free to drop them as they are unnecessary.

GraphQL’s guiding philosophy

GraphQL is designed from the ground up around a vision of APIs that evolve forever with no breaking changes. This is an ambitious goal, and it bleeds through to almost every part of the language. If you ever find yourself wondering why GraphQL works in a certain manner, it can almost always be explained in terms of this central premise.

The input and output object type split is a particularly good example of this design philosophy in action. If it were possible to use a single object type for both input and output purposes it would be extremely easy to back yourself into a corner!

When you are down in the weeds trying to push a feature out on time, it is easy to make bad decision in the moment which goes on to cause problems. Reusing a DTO for both a request and response body is a very common footgun when building RESTful APIs and gRPC services, but GraphQL makes doing this completely impossible.

Here are some other design decisions GraphQL has which enable forwards evolution and make introducing backwards-incompatible changes harder:

GraphQL is a remarkably well-considered language. At all levels of its design, it has been deliberately architected to help developers fall headfirst into the pit of success.

Don't want to miss out on new posts?

Join 100+ fellow engineers who subscribe for software insights, technical deep-dives, and valuable advice.

Get in touch 👋

If you're working on an innovative web or AI software product, then I'd love to hear about it. If we both see value in working together, we can move forward. And if not—we both had a nice chat and have a new connection.
Send me an email at hello@sophiabits.com