APIer/08 · Schema and type system
CHAPTER 08 · SDL, scalars, interfaces, unions

Schema and the type system

The schema is a written contract. It declares which types the server has, which fields those types have, and where a value may be null. Both sides read the same file, so neither has to guess.

§01

Reading SDL: the blog written as a contract

The same blog data as before: users, posts, and comments. In chapter 07 you selected fields from it. Here you read the file that declares which fields exist.

Before there was a contract

The client developer asks: is the avatar field called avatar or avatarUrl? The server developer answers: read the code. Can comments be null? Probably not. Every "probably" turns into a bug after release.

GraphQL answers this by writing it all down once. An API has one schema, written in SDL (Schema Definition Language). It declares which data the server has, the type of every field, and where a value may be null. Nobody has to guess.

blog.graphql
1type Query {
2 post(id: ID!): Post
3 posts(limit: Int = 10): [Post!]!
4 user(id: ID!): User
5}
6
7type User {
8 id: ID!
9 name: String!
10 email: String!
11 posts: [Post!]!
12}
13
14type Post {
15 id: ID!
16 title: String!
17 body: String!
18 createdAt: String!
19 author: User!
20 comments: [Comment!]!
21}
22
23type Comment {
24 id: ID!
25 body: String!
26 author: User!
27}
Look at the highlighted Query type. The return type of post(id: ID!): Post carries no exclamation mark, because asking for a post that does not exist and receiving null is a normal outcome. posts returns [Post!]!, so the list is always there, even when it is empty. Section 02 explains why nullability is a decision about the business, not about style.
type Query
The entry point for reads

Every read starts here. The many GET endpoints of a REST API become fields on Query. This is the answer to "where did the routes go" from chapter 07.

type User / Post / Comment
The nouns of your data

Each type describes the shape of one kind of data. It looks like a TypeScript interface. The difference is that this definition is shared: the server is held to it too.

name: String!
A field is a name and a type

The name goes on the left of the colon, the type on the right. A type can be a scalar, or it can be another type. Types referring to types is what connects the data into a graph (section 03).

! and [ ]
Two symbols that need a whole section

! means the value is never null. [ ] means a list. These two symbols carry the rule that surprises most people coming from other type systems. Section 02 works through it.

The same file, on both desks

The client uses the schema to validate queries and to generate types. The server uses it to line up the implementation of each field. To change a field you change the contract first, and the tools immediately mark every place where the two sides no longer match. Work that used to need a meeting becomes a compile error.

One limit is worth stating now: the schema only declares what exists. On the server, each field still needs code that produces its value. Those functions are called resolvers, and chapter 10 covers them.

§02

Scalars and modifiers: the atoms of the type system

A value that cannot be broken down into fields is a scalar. The specification defines five of them.

ScalarWhat it holdsExample
IntA signed 32-bit integer42
FloatA signed double-precision floating point number3.14
StringText, as a sequence of UTF-8 characters"hello"
Booleantrue or falsetrue
IDA unique identifier. It is always serialized as a string."9" (even when the server stores the number 9)

ID deserves a note. It looks like a String, but it means something different: this value is a key used to find one object. Whether the key is a counter, a UUID, or a hash does not matter. You should not do arithmetic on it; you use it to fetch the object again. And what about dates? There is no built-in date scalar. You either send them as String, or you define a custom scalar with your own functions for serializing and parsing the value (DateTime is a common one in the ecosystem). One more consequence of the table above: Int is 32-bit, so a value beyond 2,147,483,647 — a timestamp in milliseconds, for example — does not fit in it.

Fields are nullable by default

In TypeScript and Java, a type is not nullable unless you say so. GraphQL works the other way round: a field written without ! may return null at any time. This is deliberate. If the data source behind one field fails, the server can return null for that field alone and still deliver the rest of the response. Chapter 09 shows what those partial responses look like.

The cost is that the client has to check for null in more places. So in a real schema, every field the server can actually guarantee is marked with !. Each ! is a promise the server has to keep.

! (Non-Null) and [ ] (List) combine, which gives four ways to write a list field. The tool below explains each one: select a form to see which level may be null.

Four ways to write a list field: select one to see which level may be null
tags: [String]

The loosest form. The whole list may be null, and the list may also contain null elements. There is no ! anywhere, so nothing is ruled out.

List can be null: ✓ yesElement can be null: ✓ yes
null✓ validthe whole list is null
[]✓ validan empty list, which is not null
["a", "b"]✓ validan ordinary list
["a", null]✓ validthe list is there, but one element is null
How to read it: ! applies to the type immediately on its left. Inside the brackets it covers the elements; outside the brackets it covers the list itself. And remember that an empty list [] is always valid, because it is not null.
§03

Types as a graph: where the Graph in GraphQL comes from

Types refer to types. Post.author is a User, and User.posts is [Post!]!. Follow those references and a graph appears.

The blog schema as a graph: types are nodes, fields are edges. Select a node to read its SDL.
userpost · postsauthorpostscommentsauthorQueryentry pointUseraccountsPostarticlesCommentreplies
type Post

The busiest type here. author points at User, comments points at Comment. Both edges carry !, so once you have a post you can always ask for its author and its comments.

Definition of Post
1type Post {
2 id: ID!
3 title: String!
4 body: String!
5 createdAt: String!
6 author: User!
7 comments: [Comment!]!
8}
The highlighted edges are the references that Post points out to. The data is not a set of separate tables; it is one connected graph. That graph is the Graph in GraphQL.

From separate tables to one graph

REST splits the data into separate resources, and you join them yourself by sending another request. GraphQL describes the same data as a graph: types are the nodes, fields are the edges. A query starts at Query and walks a path you choose. "The comments on the other posts by the author of this post" is one path, written once. This is what makes the single request in chapter 07 possible.

§04

Type toolbox: enum, interface, union, input

type and the scalars do most of the work. These four cover the cases they cannot. Each one comes with an example and a note on when to use it.

enum · a closed set of values

enum · enumeration
1enum PostStatus {
2 DRAFT # written, not visible yet
3 PUBLISHED # visible to everyone
4 ARCHIVED # hidden again
5}
6
7type Post {
8 status: PostStatus!
9}
When to use it: the field can only hold a small, fixed set of values, and a typo would be expensive. Send PUBLISH instead of PUBLISHED and the server rejects the query before executing it, so the wrong value never reaches the database. Over JSON, an enum value travels as its name, which is a string. How your server represents it internally is not defined by the specification.

interface · fields that several types share

interface
1interface Node {
2 id: ID!
3}
4
5type Post implements Node {
6 id: ID!
7 title: String!
8}
9
10type User implements Node {
11 id: ID!
12 name: String!
13}
When to use it: several types really do share the same fields, and you want to handle them through one entry point — for example, anything that has a global id can be fetched by that id. A type that implements an interface must declare every field the interface declares. A query on an interface can select the shared fields directly, and uses an inline fragment when it needs a field that belongs to one specific type.

union · one of several types

union
1union SearchResult = Post | User
2
3type Query {
4 search(keyword: String!): [SearchResult!]!
5}
6
7# A union declares no fields of its own, so a query cannot
8# select a field directly. Use inline fragments, and ask for
9# __typename to know which type you received:
10# {
11# search(keyword: "graphql") {
12# __typename
13# ... on Post { title }
14# ... on User { name }
15# }
16# }
When to use it: the result can be one of several types that have no fields in common, such as a search result or a mixed feed. That is also the line between the two tools: an interface declares fields its members must all have, while a union only lists which types are possible.

input · objects you send as arguments

input · input object type
1input CreatePostInput {
2 title: String!
3 body: String!
4 status: PostStatus = DRAFT # an argument may have a default
5}
6
7type Mutation {
8 createPost(input: CreatePostInput!): Post!
9}
When to use it: a write operation needs to send a whole object of data. The specification keeps the two directions apart. A type may only be returned, and an input may only be passed in; one cannot be used where the other is expected. The type of an input field must itself be an input type: a scalar, an enum, or another input object. Chapter 09 uses input types for every mutation.
§05

Introspection: an API that describes itself

Chapter 07 left a question open: where do the documentation panel and the autocomplete in GraphiQL come from?

The answer is introspection. The schema is not only a document for people to read. It lives inside the server, and you can query it with GraphQL itself. Meta-fields whose names start with __ (two underscores) return the schema:

Introspection query
1{
2 __schema {
3 types {
4 name
5 }
6 }
7}
Response (shortened)
1{
2 "data": {
3 "__schema": {
4 "types": [
5 { "name": "Query" },
6 { "name": "User" },
7 { "name": "Post" },
8 { "name": "Comment" },
9 { "name": "String" },
10 { "name": "__Schema" }
11 ]
12 }
13 }
14}

That one query returns the name of every type in the system. Ask __type(name: "Post") and you get its fields, their arguments, and their descriptions. When GraphiQL starts, the first thing it sends is an introspection query. It then draws the documentation panel from the result and feeds the autocomplete. Every convenience in the GraphQL tooling is built on introspection.

Should introspection be turned off in production? Both sides have a case.

Turn it off: introspection tells an attacker exactly what the API contains, which makes it cheap to look for a weak field. Apollo Server disables it in production by default, and OWASP lists it as a hardening step. Leave it on: a public API such as GitHub's keeps it open, because the documentation is part of the product, and hiding the schema does not stop a determined attacker. The real defenses are an allowlist of queries and authorization on each field. Which side applies depends on whether your API is internal or public. Chapter 10 works through it.

§06

Using the contract: what happens after both sides sign

A schema is not a document you write once and file away. It sits at the center of the workflow. Three ways it gets used.

Use 1
Both sides work in parallel

Once the schema is agreed, the client team can build the interface against mock data, meaning fake responses shaped by the contract. The server team fills in the real implementation at its own pace. Neither team waits for the other, and the contract is what they check against when the two are joined.

Use 2 · option A
Schema-first: write the SDL

You write the .graphql file by hand, then write the code for each field. GraphQL Yoga with SDL is one example. The contract is the source, and anyone can read it in a review. The risk is that the SDL and the code drift apart unless something checks them against each other.

Use 2 · option B
Code-first: generate the SDL

You define the types in TypeScript code, and the SDL is exported from it. Pothos is one example. The schema cannot fall out of step with the code, and renaming works across the project in the editor. The cost is that nobody can read the schema until it has been generated. Neither route is the correct one; teams choose by how much they rely on the SDL as a shared document.

Use 3
Codegen: the contract becomes client types

A tool reads the schema and generates TypeScript types from it. When the server renames a field, the client stops compiling. A broken contract is found by the compiler instead of by a user.

Generated TypeScript types (shortened)
1// Generated by graphql-codegen from blog.graphql. Do not edit by hand.
2export type Post = {
3 id: string;
4 title: string;
5 body: string;
6 author: User;
7 comments: Comment[];
8};
The schema is the single source of truth. Change it in one place and the client types, the documentation, and the mock data all follow. Setting up that pipeline is not part of this chapter; it is enough to know that it exists.
§07

Practice

Three tasks: write a schema of your own, ask a public server to describe itself, and judge nullability the way a type checker does.

§08

Quiz

Eight questions. Nullability is the one people get wrong, so go back to section 02 if you miss it.

QUESTION 01 / 8

Which of these are built-in GraphQL scalars? (select all that apply)

QUESTION 02 / 8

A schema declares type User { name: String } and the server returns name: null. Is that valid?

QUESTION 03 / 8

How do you read tags: [String!]!?

QUESTION 04 / 8

Which statement about the ID type is correct?

QUESTION 05 / 8

Which of these fields is the best fit for an enum?

QUESTION 06 / 8

What is the key difference between an interface and a union?

QUESTION 07 / 8

What is an input type for?

QUESTION 08 / 8

The mechanism by which a schema describes itself — GraphiQL's documentation panel and autocomplete are built on it, and the query starts with two underscores (__schema). What is it called?

What to take away from this chapter
  • The schema is the contract between client and server. It declares which types exist, which fields each type has, and where a value may be null. It is written in SDL, and both sides are held to it.
  • Five built-in scalars: Int (32-bit signed), Float, String, Boolean, and ID. ID is an opaque key that is always serialized as a string, so do not do arithmetic on it.
  • Fields are nullable by default; ! is the promise. ! applies to the type immediately on its left, so in [String!]! the inner one covers the elements and the outer one covers the list. A null in a non-null position moves up to the nearest parent field that allows null.
  • Types refer to types, so the data forms a graph. That graph is the Graph in GraphQL, and it is what lets one query walk several levels of related data.
  • Four more tools: enum for a closed set of values, interface for fields several types share, union for a result that is one of several unrelated types, and input for objects sent as arguments. type and input are not interchangeable — that is a rule of the specification.
  • Introspection lets the schema describe itself (__schema, __type). GraphiQL's documentation and autocomplete come from it. Whether to keep it on in production depends on who the API is open to.