NodeJSSchemas & Type Definitions

Schemas & Type Definitions

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