The Spec, Simplified: The Type System

Loren Sands-Ramshaw
The GraphQL Guide
Published in
10 min readJun 11, 2021

Haven’t gotten around to reading the GraphQL spec yet? Here’s the type system in plain language 🤓

Excerpt from the spec

This is a series of posts explaining the essential parts of the GraphQL specification:

Part 2: Type system

Most people who use GraphQL haven’t read the spec, often because it sounds or looks intimidating. In this post, we’ll go over the query language section of the spec, which has these parts:

  • Schema
  • Types
  • Descriptions
  • Scalars
  • Enums
  • Objects
  • Interfaces
  • Unions
  • Lists
  • Non-null
  • Field arguments
  • Input objects
  • Directives
  • Extending
  • Introspection

Schema

The schema defines the capabilities of a GraphQL server. It defines the possible queries, mutations, subscriptions, and additional types and directives. While the schema can be written in a programming language, it is often written in SDL (the GraphQL Schema Definition Language). Here is the most basic schema, written in SDL:

schema {
query: Query
}
type Query {
hello: String
}

It has a single root query field, hello, of type String (when we send a hello query, the server will return a string value). We can omit the schema declaration when we use operation types named Query, Mutation, and Subscription, so the above is equivalent to:

type Query {
hello: String
}

With this schema, the client can make the below query:

query {
hello
}

and receive this response:

{
"data": {
"hello": "world!"
}
}

The root fields — those listed under type Query { ... }, type Mutation { ... }, and type Subscription { ... } — are the entry points to our schema. They’re the fields that can be selected by the client at the root level of an operation.

Types

There are six named types and two wrapping types. The named types are:

  • Scalar
  • Enum
  • Object
  • Input object
  • Interface
  • Union

If you think of a GraphQL query as a tree, starting at the root field and branching out, the leaves are either scalars or enums. They’re the fields without selection sets of their own.

The two wrapping types are:

  • List
  • Non-null

When the named types appear by themselves, they are singular and nullable — i.e., when the client requests a field, the server will return either one item or null. Using a wrapping type changes this default behavior.

Descriptions

We can add a description before any definition in our schema using " or """. Descriptions are included in introspection and displayed by tools like GraphiQL, an IDE for creating GraphQL queries. Some libraries (like graphql-tools) also treat comments—lines that start with #—as descriptions, even though according to the spec they’re supposed to be ignored.

type Query {
"have the server say hello to whomever you want!"
hello(
"person to say hello to"
name: String!
): String
}
"""
multiline description
of the User type
"""
type User {
id: Int
email: String
}

Scalars

Scalars are primitive values. There are five included scalar types:

  • Int: Signed 32-bit non-fractional number. Maximum value around 2 billion (2,147,483,647).
  • Float: Signed double-precision (64-bit) fractional value.
  • String: Sequence of UTF-8 (8-bit Unicode) characters.
  • Boolean: true or false.
  • ID: Unique identifier, serialized as a string.

We can also define our own scalars, like Url and DateTime. In the description of our custom scalars, we write how they’re serialized so the frontend developer knows what value to provide for arguments. For instance, DateTime could be serialized as an integer (milliseconds since Epoch) or as an ISO string:

scalar DateTimetype Mutation {
dayOfTheWeek(when: DateTime): String
}

Given the above schema, the client would send one of the below operations, depending on the definition of DateTime :

# if DateTime is serialized as an integer
mutation {
dayOfTheWeek(when: 1591028749941)
}
# if DateTime is serialized as an ISO string
mutation {
dayOfTheWeek(when: "2020-06-01T16:25:49.941Z")
}

The benefits to using custom scalars are clarity (when: DateTime is clearer than when: Int) and consistent validation (whatever value we pass is checked to make sure it’s a valid DateTime).

Enums

When a scalar field has a small set of possible values, it’s best to use an enum instead. The enum type declaration lists all the options:

enum Direction {
NORTH
EAST
SOUTH
WEST
}

Enums are usually serialized as strings (for example, "NORTH"). Here’s an example Query type, query operation, and response:

type Query {
currentHeading(flightId: ID): Direction
}
query {
currentHeading(flightId: "abc")
}
{
"data": {
"currentHeading": "NORTH"
}
}

Objects

An object is a list of fields, each of which have a name and a type. The below schema defines two object types, Post and User:

type Post {
id: ID
text: String
author: User
}
type User {
id: ID
name: String
}

A field’s type can be any type but an input object. In the Post type, the id and text fields are scalars, while author is an object type.

When selecting a field that has an object type, at least one of that object’s fields must be selected. For instance, in the below schema, thepost field is of type Post:

type Query {
post(id: ID): Post
}

Since Post is an object type, at least one Post field must be selected in query A below—in this case, text is selected. And in query B, post.author is of type User, so at least one User field must be selected.

query A {
post(id: "abc") {
text
}
}
query B {
post(id: "abc") {
author {
name
}
}
}

In other words, we have to keep adding selection sets until we only have leaves (scalars and enums) left. Objects are the branches on the way to the leaves.

Interfaces

Interfaces define a list of fields that must be included in any object types implementing them. For instance, here are two interfaces, BankAccount and InsuredAccount, and an object type that implements them, CheckingAccount:

interface BankAccount {
accountNumber: String!
}
interface InsuredAccount {
insuranceName: String
insuranceAmount: Int!
}
type CheckingAccount implements BankAccount & InsuredAccount {
accountNumber: String!
insuranceName: String
insuranceAmount: Int!
routingNumber: String!
}

Since CheckingAccount implements both interfaces, it must include the fields from both. It can also include additional fields, like routingNumber.

Interfaces can implement other interfaces, like this:

interface InvestmentAccount implements BankAccount {
accountNumber: String!
marginApproved: Boolean!
}
type RetirementAccount implements InvestmentAccount {
accountNumber: String!
marginApproved: Boolean!
contributionLimit: Int!
}

Interfaces are helpful for clarity and consistency in the schema, but they’re also useful as field types:

type Query {
user(id: ID!): User
}
type User {
id: ID!
name: String!
accounts: [BankAccount]
}

We can now query for fields in BankAccount:

query {
user(id: "abc") {
name
accounts {
accountNumber: String!
}
}
}

And if we want to query fields outside BankAccount, we can use a fragment:

query {
user(id: "abc") {
name
accounts {
accountNumber: String!
... on RetirementAccount {
marginApproved
contributionLimit
}
}
}
}

Unions

A union type is defined as a list of object types:

union SearchResult = User | Posttype User {
name: String
profilePic: Url
}
type Post {
text: String
upvotes: Int
}

When a field is typed as a union, its value can be any of the objects listed in the union definition. So with this schema:

type Query {
search(term: String): SearchResult
}

the below search query returns a list of User and Post objects:

query {
search(term: "John") {
... on User {
name
}
... on Post {
text
}
}
}

Since unions don’t guarantee any fields in common, any field we select has to be inside a fragment (which has a specific object type).

Lists

List is a wrapper type. It wraps another type and signifies an ordered list in which each item is of the wrapped type.

type User {
names: [String]
}

The User.names field could be any of these values:

null
[]
[null]
["Loren"]
["Loren", null, "L", "Lolo"]

We can also nest lists, like Spreadsheet.cells:

type Spreadsheet {
columns: [String]
rows: [String]
cells: [[Int]]
}

For example:

{
"columns": ["Revenue", "Expenses"],
"rows": ["Jan", "Feb", "March"],
"cells": [[100, 110], [200, 100], [300, 50]]
}

Non-null

Non-null is a wrapper type. It wraps any other type and signifies that type can’t be null.

type User {
name: String!
}

If we select User.name in a query:

query {
user(id: "abc") {
name
}
}

then we will never get this response:

{
"data": {
"user": {
"name": null
}
}
}

These two responses are valid:

{
"data": {
"user": {
"name": "Loren"
}
}
}
{
"data": {
"user": null
}
}

Field arguments

Any field can accept a named, unordered list of arguments. Arguments can be scalars, enums, or input objects. An argument can be non-null to indicate it is required. Optional arguments can have a default value, like name below.

type User {
# no arguments
name
# an optional scalar argument with a default value
profilePic(width: Int = 100): Url
}
type Mutation {
# a non-null enum argument
pokemonGo(direction: Direction!): Boolean
# three non-null scalar arguments
createPost(authorId: ID!, title: String!, body: String!): Post
}

Input objects

Input objects are objects that are only used as arguments. An input object is often the sole argument to mutations.

An input object is a list of input fields — scalars, enums, and other input objects.

type Mutation {
createPost(input: CreatePostInput!): Post
}
input CreatePostInput {
authorId: ID!
title: String = "Untitled"
body: String!
}

Note that:

  • Input object fields can have default values.
  • The declaration keyword is input, not the type keyword that is used for output objects.

Directives

We talked about the query side of directives in Part 1. Directives are declared in the schema. A directive definition includes its name, any arguments, on what types of locations it can be used, and whether it’s repeatable (can be used multiple times in the same location):

directive @authoredBy(name: String!) repeatable on OBJECTtype Book @authoredBy(name: "pageCount") @authoredBy(name: "author") {
id: ID!
}

The locations can either be in executable documents (requests from the client) or schema documents.

# Executable locations
QUERY
MUTATION
SUBSCRIPTION
FIELD
FRAGMENT_DEFINITION
FRAGMENT_SPREAD
INLINE_FRAGMENT
VARIABLE_DEFINITION
# Schema locations
SCHEMA
SCALAR
OBJECT
FIELD_DEFINITION
ARGUMENT_DEFINITION
INTERFACE
UNION
ENUM
ENUM_VALUE
INPUT_OBJECT
INPUT_FIELD_DEFINITION

Directives can work on multiple locations, like @deprecated:

directive @deprecated(
reason: String = "No longer supported"
) on FIELD_DEFINITION | ENUM_VALUE
type Direction {
NORTHWEST @deprecated
NORTH
EAST
SOUTH
WEST
}
type User {
id: Int!
name: String
fullName: String @deprecated("Use `name` instead")
}

Extending

All named types can be extended in some way. We might extend types when we’re defining our schema across multiple files, or if we’re modifying a schema defined by someone else. Here are a couple examples:

type Query { 
messages: [String]
}
type Direction {
NORTH
EAST
SOUTH
WEST
}
extend type Query {
lastMessage: String
}
extend enum Direction {
SOUTHEAST
SOUTHWEST
NORTHEAST
NORTHWEST
}

First we add a root query field, and then we add four more possible values for the Direction enum.

Introspection

GraphQL servers support introspection — the ability to query for information about the server’s schema. There are three introspection entry fields:

  • __schema: __Schema!
  • __type(name: String!): __Type
  • __typename: String

The first two are root query fields, and __typename is an implicit field on all objects, interfaces, and unions. We can use __typename in the search query from the Interfaces section:

query {
search(term: "John") {
__typename
... on User {
name
}
... on Post {
text
}
}
}

And the response will include the name of the result object’s type:

{
"data": {
"search": {
"__typename": "Post",
"text": "John Resig joins Khan Academy to provide free education to everyone."
}
}
}

With the introspection root query fields we can either get information about a single type (like the below query) or all types (via query { __schema { types { ... } } }).

query {
__type(name: "User") {
name
fields {
name
type {
name
}
}
}
}

Sending the above operation results in this response:

{
"__type": {
"name": "User",
"fields": [
{
"name": "id",
"type": { "name": "ID" }
},
{
"name": "name",
"type": { "name": "String" }
}
]
}
}

As we can see, the response shows all the information in the schema about a User:

type User {
id: ID
email: String
}

Here is the full introspection schema:

extend type Query {
__schema: __Schema!
__type(name: String!): __Type
}
type __Schema {
description: String
types: [__Type!]!
queryType: __Type!
mutationType: __Type
subscriptionType: __Type
directives: [__Directive!]!
}
type __Type {
kind: __TypeKind!
name: String
description: String
# should be non-null for OBJECT and INTERFACE only, must be null for the others
fields(includeDeprecated: Boolean = false): [__Field!]
# should be non-null for OBJECT and INTERFACE only, must be null for the others
interfaces: [__Type!]
# should be non-null for INTERFACE and UNION only, always null for the others
possibleTypes: [__Type!]
# should be non-null for ENUM only, must be null for the others
enumValues(includeDeprecated: Boolean = false): [__EnumValue!]
# should be non-null for INPUT_OBJECT only, must be null for the others
inputFields: [__InputValue!]
# should be non-null for NON_NULL and LIST only, must be null for the others
ofType: __Type
}
type __Field {
name: String!
description: String
args: [__InputValue!]!
type: __Type!
isDeprecated: Boolean!
deprecationReason: String
}
type __InputValue {
name: String!
description: String
type: __Type!
defaultValue: String
}
type __EnumValue {
name: String!
description: String
isDeprecated: Boolean!
deprecationReason: String
}
enum __TypeKind {
SCALAR
OBJECT
INTERFACE
UNION
ENUM
INPUT_OBJECT
LIST
NON_NULL
}
type __Directive {
name: String!
description: String
locations: [__DirectiveLocation!]!
args: [__InputValue!]!
isRepeatable: Boolean!
}
enum __DirectiveLocation {
QUERY
MUTATION
SUBSCRIPTION
FIELD
FRAGMENT_DEFINITION
FRAGMENT_SPREAD
INLINE_FRAGMENT
SCHEMA
SCALAR
OBJECT
FIELD_DEFINITION
ARGUMENT_DEFINITION
INTERFACE
UNION
ENUM
ENUM_VALUE
INPUT_OBJECT
INPUT_FIELD_DEFINITION
}

Summary

To recap the GraphQL type system, the schema defines the capabilities of a GraphQL server. It is made up of type and directive definitions, which consist of values or fields and their types and arguments. Types, fields, arguments, and enum values can all have descriptions — strings provided to introspection queries.

There are six named types:

  • Scalars are primitive values.
  • Enums have a defined set of possible values.
  • Objects have a list of fields.
  • Input objects are objects that can be used as arguments.
  • Interfaces have a list of fields that all implementing objects must include.
  • Unions are a list of object types.

And there are two wrapping types:

  • Lists denote ordered lists.
  • Non-null denote types that can’t resolve to null.

Schema directives define directives that can be used in query documents. Types can be extended after they’re defined. And GraphQL servers can have introspection turned on, which enables two specific root Query fields that return information about the schema.

The next post in the series is on validation and execution of GraphQL documents.

This post is an excerpt from our book, The GraphQL Guide. At 886 pages, it’s the complete reference text for GraphQL, with a beginner intro as well as advanced client and server topics. Grab a copy at graphql.guide 🤗

--

--