Skip to main content

📐 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:

  • type defines an object type with named fields.
  • Each field is written name: Type.
  • A trailing ! marks a field non-nullable — it will never be null in 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
IntSigned 32-bit integer1, 42, -7
FloatSigned double-precision floating point3.14159, -2.5, 6.02e23
StringUTF-8 character sequence"Hello", "GraphQL"
Booleantrue or falsetrue, false
IDUnique 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.

graph LR User -->|authors| Posts Posts -->|has| Comments User -->|writes| Comments

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!
}
graph TD A[Schema root] --> B[Query — read] A --> C[Mutation — write] A --> D[Subscription — real-time] B --> B1[user / users] B --> B2[post / posts] C --> C1[createUser] C --> C2[updateUser / deleteUser] D --> D1[userCreated] D --> D2[postCreated]
  • 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/skip arguments 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

🚀 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.