The schema is the heart of a GraphQL API — a strongly-typed contract, written in the Schema Definition Language (SDL), that declares every type, field, and operation the API exposes. Clients and tooling read it to know exactly what's available; the server validates every incoming query against it before a single resolver runs. Get the schema right and everything else — resolvers, client code, documentation — follows from it. This page covers SDL syntax, the scalar and object types, nullability and lists, arguments, enums, interfaces, unions, input types, the three root types, and how to evolve a schema without versioning.
SDL — describing types in a typed language
GRAPHQL
# An object type: a named set of fields, each with its own type.
type User {
id: ID! # ID = an opaque unique identifier (serialized as a string)
name: String! # ! means NON-NULL — this field can never be null
email: String!
age: Int # nullable — may be absent
isVerified: Boolean!
posts: [Post!]! # a non-null list of non-null Posts
}
type Post {
id: ID!
title: String!
body: String!
author: User! # types reference each other → a graph
}
A schema is built from object types whose fields have types — and types reference each other, forming a graph
SDL is a small, declarative language for describing data shapes. The core building block is the **object type** (`type User { ... }`): a named collection of **fields**, each annotated with the type it returns. Fields can be **scalars** (`String`, `Int`), other object types (`author: User`), or lists of either. Because one type's field can point at another type, the schema naturally forms a **graph** of interconnected types — which is what lets a client traverse from a user to their posts to each post's comments in a single query. The schema describes *what data exists and how it's shaped*; it says nothing about *where* the data comes from — that's the [resolvers'](/nodejs/graphql-resolvers) job.
Scalar types — the leaf values
Scalar
Holds
Notes
Int
Signed 32-bit integer
No 64-bit integers — use a custom scalar or String for big numbers
Float
Signed double-precision number
All non-integer numbers
String
UTF-8 text
The most common scalar
Boolean
true / false
ID
Unique identifier
Serialized as a string; signals "this is a key, not display text"
Scalars are the leaves of every query — five built in, and you define your own for things like dates
Every branch of a GraphQL query eventually terminates in a **scalar** — the concrete leaf values. GraphQL ships five: `Int`, `Float`, `String`, `Boolean`, and `ID`. `ID` is special only semantically: it's serialized as a string but signals "this is an identifier, not human-readable text," which tooling and caching rely on. Notably there's **no built-in date/time scalar** — the convention is to define a **custom scalar** (`scalar DateTime`) and provide serialize/parse logic for it, or lean on a library like `graphql-scalars` for `DateTime`, `EmailAddress`, `URL`, and others. Custom scalars let you validate and normalize values at the type-system boundary.
Nullability and lists — read the `!` and `[]` carefully
GRAPHQL
field: String # nullable string → String OR null
field: String! # non-null string → always a String, never null
field: [String] # nullable list of nullable strings → null, or [a, null, b]
field: [String]! # non-null list of nullable strings → [a, null, b], never null
field: [String!] # nullable list of non-null strings → null, or [a, b]
field: [String!]! # non-null list of non-null strings → [a, b], never null itself
# nor any null elements — the strictest, most common choice
By default fields are NULLABLE — and a non-null field that resolves to null nullifies its parent
In GraphQL the default is **nullable**; you add `!` to promise non-null. This matters more than it looks. When a field marked `!` resolves to `null` (a bug, or a thrown error), GraphQL can't return null for a non-null field — so it **propagates the null upward**, nullifying the nearest *nullable* parent, potentially wiping out a large chunk of the response. The lesson: mark a field `!` only when it is *genuinely, always* present; over-using `!` makes your API fragile to partial failures. Lists have **two** nullability positions — the list itself and its elements — so `[Post!]!` (non-null list of non-null posts) is a deliberate, common choice that says "always an array, never with null holes."
Arguments — parameterizing fields
GRAPHQL
type Query {
# Fields can take arguments — typed, optionally with defaults:
user(id: ID!): User
posts(first: Int = 10, after: String, status: PostStatus): [Post!]!
search(term: String!): [SearchResult!]!
}
# Arguments aren't only on root fields — any field can take them:
type User {
posts(first: Int = 5): [Post!]! # paginate a user's posts
avatar(size: Int = 64): String! # request a specific image size
}
Any field can take typed arguments — used for lookups, filtering, pagination, and shaping nested data
**Arguments** parameterize a field: `user(id: ID!)` takes the id to look up, `posts(first: Int = 10)` paginates with a default page size. Arguments are typed (and validated like everything else), and can have **default values**. Crucially, arguments aren't limited to top-level `Query` fields — *any* field can accept them, so a client can ask for `user { posts(first: 3) }` to get just three of that user's posts, or `avatar(size: 128)`. This is what makes nested data shaping so expressive. Argument values are passed to the field's resolver as its second parameter. For non-trivial inputs (especially mutations) you group arguments into an **input type** rather than listing many scalars.
Enums, interfaces, and unions
GRAPHQL
# ENUM — a fixed set of allowed values (validated by the type system):
enum PostStatus { DRAFT, PUBLISHED, ARCHIVED }
# INTERFACE — shared fields that multiple types implement:
interface Node { id: ID! }
type User implements Node { id: ID!, name: String! }
type Post implements Node { id: ID!, title: String! }
# UNION — a value that is one of several types (no shared fields required):
union SearchResult = User | Post | Comment
# Querying a union/interface uses inline fragments to pick type-specific fields:
# search(term: "ada") {
# ... on User { name }
# ... on Post { title }
# }
Construct
Use it for
Members share fields?
enum
A closed set of named constants (status, role, sort order)
N/A
interface
Different types that share a common set of fields
Yes — the interface fields
union
A field that returns one of several unrelated types
No
Enums constrain values; interfaces share fields across types; unions return one of several types
Beyond objects and scalars, SDL gives you three abstraction tools. **Enums** define a fixed set of allowed values — the type system rejects anything else, which is safer and more self-documenting than passing magic strings. **Interfaces** declare fields that multiple object types must implement (`Node { id }` is the classic example, enabling generic "fetch any object by id"), letting a field return "any type that has these fields." **Unions** express "one of these types" where the members *don't* share fields (a search result that's a User *or* a Post *or* a Comment). For both interfaces and unions, the client uses **inline fragments** (`... on User { ... }`) to select the fields specific to each possible concrete type.
Input types — structured arguments for mutations
GRAPHQL
# INPUT types are like object types, but for ARGUMENTS (data coming IN).
# They can only contain scalars, enums, and other input types — no object types.
input CreateUserInput {
name: String!
email: String!
role: UserRole = MEMBER
}
type Mutation {
# Group related arguments into a single input — cleaner and easier to evolve:
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
}
`input` types bundle arguments for mutations — keep input and output types separate
GraphQL deliberately separates **output types** (`type`, what you read) from **input types** (`input`, what you send as arguments). An input type looks like an object type but may contain only scalars, enums, and other input types — never output object types. The convention is to wrap a mutation's arguments in a single input (`createUser(input: CreateUserInput!)`) rather than passing a long list of positional scalars: it reads better, supports defaults, and is far easier to evolve (add an optional field without touching the call signature). Resist the temptation to reuse one type for both input and output — their shapes diverge quickly (output has `id`, `createdAt`; input doesn't), and GraphQL forbids it anyway.
The three root types — entry points
GRAPHQL
# These three special types are the ENTRY POINTS into the graph:
type Query { # all reads start here
me: User
user(id: ID!): User
posts(first: Int = 10): [Post!]!
}
type Mutation { # all writes start here
createPost(input: CreatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
type Subscription { # all real-time streams start here
postAdded: Post!
}
`Query`, `Mutation`, and `Subscription` are the roots where every operation must begin
Three specially-named types are the **roots** of the schema, the only places an operation can start. **`Query`** holds every read entry point; **`Mutation`** every write; **`Subscription`** every real-time [stream](/nodejs/server-sent-events). A client operation names which root it's hitting (`query { ... }`, `mutation { ... }`) and then selects fields from there, descending into the graph. `Query` is the only required root — many APIs have no subscriptions, and a read-only API has no mutations. Everything reachable in the entire API is reachable by starting at one of these three and following fields. Designing good root fields (clear names, sensible arguments, the right granularity) is most of schema design.
Evolving a schema — add, don't version
GraphQL APIs evolve without `/v2/` — add fields freely, but deprecate (never silently remove) what clients use
A major GraphQL selling point is **versionless evolution**. Because clients request only the fields they want, you can **add** new types, fields, and arguments at any time without breaking anyone — existing queries don't ask for the new fields, so they're unaffected. There's no need for `/v2/` URLs like in [REST](/nodejs/graphql-vs-rest). What you must *not* do is **break existing clients**: removing a field, renaming it, making a nullable field non-null, or changing its type can break live queries. Instead, mark the old field `@deprecated(reason: "Use fullName")` — it stays functional, tooling shows it as deprecated, and you watch usage analytics until traffic drops to zero before removing it. **Add freely, deprecate gracefully, remove only when unused.** This discipline, plus a schema registry that flags breaking changes in CI, is how teams evolve a GraphQL API safely.
Next
The schema says what exists; now make it return data: [Resolvers](/nodejs/graphql-resolvers).