📐 Schema Definition and Types
The schema is the beating heart of a GraphQL API — a strongly-typed contract that says exactly what data exists and what clients may ask for. Get the schema right and the rest of your API becomes self-documenting, tool-friendly, and safe to evolve. This lesson teaches you to read and write GraphQL's Schema Definition Language fluently.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Write a schema in the Schema Definition Language (SDL), using nullability and list notation correctly
- Use the built-in scalar types and know when to reach for custom scalars
- Model object types and relationships (one-to-one, one-to-many, many-to-many)
- Apply input types, enums, interfaces, and unions where each fits
- Define schema entry points (Query, Mutation, Subscription) and annotate fields with directives
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Design a complete schema for a restaurant ordering system.
In This Lesson
The Schema as a Contract
A GraphQL schema is the blueprint of your API. It declares every type, every field, and every operation clients can perform. Because it is strongly typed, the server can validate any incoming query before executing it — a request for a field that doesn't exist fails fast with a clear error, never a mysterious runtime crash.
The schema is a binding contract between client and server. It states three things unambiguously:
- What data can be requested
- What operations can be performed
- What shape the responses will take
💡 A blueprint analogy. A schema is like an architect's blueprint. It shows which rooms exist and how they connect (types and relationships), marks the doors you can enter through (query entry points), notes where utilities hook up (mutations), and lets an inspector verify the building against the plan (type checking). Renovations start by updating the blueprint (schema evolution) — never by knocking down a wall in secret.
Schema Definition Language
Schemas are written in the Schema Definition Language (SDL) — a compact, human-readable syntax that is the same in every GraphQL implementation. Here is a small blog schema:
# A basic schema for a blog
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
author: User!
comments: [Comment!]!
createdAt: String!
}
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
createdAt: String!
}
Four pieces of SDL syntax carry most of the meaning:
typedefines an object type with named fields.- Each field is written
name: Type. - A trailing
!marks a field non-nullable — it will never benullin a response. - Square brackets
[Type]denote a list. - Lines beginning with
#are comments.
⚠️ Read list nullability carefully
[Comment!]! has two exclamation marks, and they mean different things. The inner ! says every item in the list is non-null (no null holes). The outer ! says the list itself is never null (an empty list [] is fine, but null is not). So [Comment!]! = "always a list, and every element is a real Comment."
Scalar Types
Scalars are the leaf values of a query — they resolve to concrete data rather than to nested objects. GraphQL ships with five built-in scalars:
| Scalar | Description | Example values |
|---|---|---|
Int | Signed 32-bit integer | 1, 42, -7 |
Float | Signed double-precision floating point | 3.14159, -2.5, 6.02e23 |
String | UTF-8 character sequence | "Hello", "GraphQL" |
Boolean | true or false | true, false |
ID | Unique identifier, serialized as a string | "123", "abc123" |
📖 Why ID and not String?
ID serializes as a string but signals intent: this value is an opaque identifier, not human-readable text. Tools and clients treat it accordingly — for example, caching libraries key their normalized store on ID fields.
Custom scalar types
The built-in set is deliberately small. For specialized formats you can declare custom scalars and teach the server how to serialize, parse, and validate them:
scalar DateTime
scalar Email
scalar URL
scalar JSON
type User {
id: ID!
email: Email!
dateOfBirth: DateTime
profileUrl: URL
metadata: JSON
}
Declaring a custom scalar in SDL is only half the job — you must also provide its serialize/parse logic in the server (for example with the widely used graphql-scalars library, which supplies battle-tested DateTime, EmailAddress, and URL scalars so you don't hand-roll validation).
Object Types & Relationships
Object types are the workhorses of a schema: entities with named fields. Their fields can point at other object types, and that is how the data graph is formed.
Relationships come in three flavors, all expressed purely by field types:
- One-to-one — a user has exactly one profile.
- One-to-many — a user has many posts.
- Many-to-many — users can like many posts; posts can be liked by many users.
type User {
id: ID!
name: String!
profile: Profile! # one-to-one
posts: [Post!]! # one-to-many
likedPosts: [Post!]! # many-to-many
}
type Post {
id: ID!
title: String!
author: User! # many-to-one
likedBy: [User!]! # many-to-many
}
type Profile {
id: ID!
bio: String
user: User! # one-to-one
}
These bidirectional links are what let a client traverse the graph in a single query — from a user, down to their posts, and back up to who liked each one.
Inputs & Enums
Input types
You cannot pass a regular object type as an argument. Instead you declare an input type with the input keyword. Input types bundle related arguments — especially handy for mutations:
input CreateUserInput {
name: String!
email: String!
password: String!
}
input UpdateUserInput {
name: String
email: String
password: String
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
}
Input types have two rules worth memorizing: their fields may only be scalars, enums, or other input types (never object types), and grouping arguments into an input makes the API far easier to evolve — you add an optional field instead of changing a mutation's signature.
💡 A common pattern
Notice CreateUserInput marks fields non-null while UpdateUserInput makes them all optional. That's intentional: creating requires every field, but updating should let a client change just one. This "create is strict, update is loose" split is a standard convention.
Enumeration types
An enum restricts a field to a fixed set of named values. Use them wherever a field can only be one of a known list:
enum UserRole {
ADMIN
EDITOR
VIEWER
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
type User {
id: ID!
name: String!
role: UserRole!
}
Enums are self-documenting (clients see the allowed values), type-safe (invalid values are rejected at validation time), and improve editor autocomplete.
Interfaces & Unions
Interfaces
An interface is an abstract type: a set of fields that implementing types must include. It gives you polymorphism with shared, guaranteed fields.
interface Node {
id: ID!
}
interface Content {
title: String!
createdAt: String!
}
type Post implements Node & Content {
id: ID!
title: String!
createdAt: String!
body: String!
author: User!
}
type Comment implements Node & Content {
id: ID!
title: String!
createdAt: String!
text: String!
author: User!
}
Now a client can query any Content and rely on title and createdAt being present, regardless of the concrete type.
Unions
A union is a type that could be one of several object types, but — unlike an interface — the members share no common fields. Unions shine for search results:
union SearchResult = User | Post | Comment
type Query {
search(term: String!): [SearchResult!]!
}
Because the members differ, you select fields with inline fragments, using ... on TypeName:
query Search($term: String!) {
search(term: $term) {
__typename
... on User { name email }
... on Post { title body }
... on Comment { text author { name } }
}
}
📖 Interface vs. union — which one?
Use an interface when the types share fields you want to query uniformly (every Content has a title). Use a union when the types are genuinely unrelated and only travel together in one list (search hits). The __typename meta-field tells the client which concrete type each result actually is.
Entry Points & Directives
Every schema has up to three special root types that serve as entry points:
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
type Query {
user(id: ID!): User
users: [User!]!
post(id: ID!): Post
posts: [Post!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
type Subscription {
userCreated: User!
postCreated: Post!
}
- Query — read operations (the only required root type).
- Mutation — operations that change data (optional).
- Subscription — real-time updates, typically over WebSockets (optional).
Directives
Directives annotate schema elements with metadata or behavior. The built-in @deprecated is the one you'll use most; custom directives (for auth, formatting, or caching) are also common:
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role { ADMIN USER GUEST }
type User {
id: ID!
email: String!
hashedPassword: String! @auth(requires: ADMIN)
oldField: String @deprecated(reason: "Use newField instead")
newField: String
}
Documenting the schema
GraphQL schemas are self-documenting. Wrap a description in triple quotes above any type or field, and it flows into introspection so tools like GraphiQL show it inline:
"""
A user in the system. Users can create posts and comments.
"""
type User {
"Unique identifier for the user"
id: ID!
"Full name shown on the profile"
name: String!
"Email address used for login"
email: String!
}
A Worked Example
Let's pull every concept together into a realistic schema for a book-review application. Read it top to bottom and spot the object types, relationships, enums, inputs, and root types:
type Query {
book(id: ID!): Book
books(genre: Genre, searchTerm: String, first: Int, skip: Int): [Book!]!
author(id: ID!): Author
reviews(bookId: ID, userId: ID): [Review!]!
me: User
}
type Mutation {
signup(input: SignupInput!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
createBook(input: CreateBookInput!): Book!
createReview(input: CreateReviewInput!): Review!
}
type AuthPayload {
token: String!
user: User!
}
type User {
id: ID!
name: String!
email: String!
reviews: [Review!]!
}
type Author {
id: ID!
name: String!
bio: String
books: [Book!]!
}
type Book {
id: ID!
title: String!
summary: String!
pageCount: Int
genre: Genre!
author: Author!
reviews: [Review!]!
averageRating: Float
}
type Review {
id: ID!
rating: Int!
text: String
book: Book!
user: User!
createdAt: String!
}
enum Genre {
FICTION
NON_FICTION
SCIENCE_FICTION
FANTASY
MYSTERY
BIOGRAPHY
}
input SignupInput {
name: String!
email: String!
password: String!
}
input CreateBookInput {
title: String!
summary: String!
pageCount: Int
genre: Genre!
authorId: ID!
}
input CreateReviewInput {
bookId: ID!
rating: Int!
text: String
}
✅ Schema design habits worth building
- Evolve, don't version. Add fields rather than breaking existing ones; deprecate with
@deprecated. - Design for the consumer, not your database tables — model the graph clients actually traverse.
- Paginate any list that could grow (note the
first/skiparguments above). - Be deliberate about nullability — mark a field non-null only when it truly always has a value.
- Keep mutations focused: each should do one thing well and return the affected object.
Hands-on Exercise
🏋️ Design a restaurant-ordering schema
Objective: Practice modelling a real domain in SDL.
Requirements:
- Restaurants have a name, address, cuisine, and operating hours.
- Menu items have a name, description, price, and dietary info.
- Customers place orders containing multiple items.
- Orders have a status (pending, preparing, ready, delivered).
- Customers can review restaurants.
Your schema should include:
- All necessary object types and their relationships
- Query and Mutation entry points
- At least one enum and one input type
💡 Hint
Start by listing your nouns — those become object types (Restaurant, MenuItem, Order, OrderItem, Review). Then draw the arrows between them (a Restaurant has many MenuItems; an Order has many OrderItems). Order status is a perfect enum. The "place an order" argument bundle is your input type.
✅ Sample solution
enum OrderStatus { PENDING PREPARING READY DELIVERED }
type Restaurant {
id: ID!
name: String!
address: String!
cuisine: String!
hours: String!
menu: [MenuItem!]!
reviews: [Review!]!
}
type MenuItem {
id: ID!
name: String!
description: String
price: Float!
dietaryInfo: [String!]!
}
type OrderItem {
item: MenuItem!
quantity: Int!
}
type Order {
id: ID!
customer: User!
items: [OrderItem!]!
status: OrderStatus!
total: Float!
}
type Review {
id: ID!
restaurant: Restaurant!
author: User!
rating: Int!
comment: String
}
input OrderItemInput { menuItemId: ID! quantity: Int! }
input PlaceOrderInput {
restaurantId: ID!
items: [OrderItemInput!]!
}
type Query {
restaurant(id: ID!): Restaurant
searchMenuItems(term: String!): [MenuItem!]!
}
type Mutation {
placeOrder(input: PlaceOrderInput!): Order!
updateOrderStatus(id: ID!, status: OrderStatus!): Order!
}
Yours may differ — what matters is clear types, sensible relationships, an enum for status, and an input for placing orders.
Quiz
🎯 Check Your Understanding
Question 1: What does the field type [Comment!]! guarantee?
Question 2: Which restriction is true of input types?
Question 3: You need a search that returns users, posts, and comments in one list, and these types share no common fields. What do you use?
Summary
🎉 Key Takeaways
- The schema is a strongly-typed contract written in SDL that clients and servers both trust.
!marks non-null and[]marks lists — read the two!in[T!]!separately.- Five built-in scalars cover the basics; custom scalars handle formats like dates and emails.
- Object types and their fields form the data graph; inputs, enums, interfaces, and unions round out the type system.
- Query, Mutation, Subscription are the entry points; directives and descriptions add metadata and docs.
📚 Further Reading
- GraphQL.org — Schemas and types
- Apollo Server — Schema basics
- graphql-scalars — Ready-made custom scalars
- Principled GraphQL — Design best practices
🚀 What's Next?
A schema describes what is possible; resolvers make it happen. Next up — Queries, Mutations, and Resolvers — you'll write the functions that turn this contract into a working API.
🎉 Well done!
You can now read and design a GraphQL schema. Let's bring it to life with resolvers.