backend / protocols / graphql / 02_schema_and_types.md

GraphQL Schema and Types

6 interview angles 7 min read source

GraphQL Schema and Types

The schema is the contract. Written in SDL (Schema Definition Language) — a small DSL for describing types, fields, inputs, and operations.

Core building blocks

type User {                       # object type
  id: ID!                          # field with non-null scalar
  name: String!
  email: String
  age: Int
  isActive: Boolean!
  balance: Float
  joinedAt: DateTime               # custom scalar
  role: Role!                      # enum
  posts: [Post!]!                  # list of non-null Posts; list itself non-null
  manager: User                    # nullable self-reference
}
Means
Int, Float, String, Boolean, ID built-in scalars
! non-null (required)
[T] list of T (nullable) — could be null or empty
[T!] list of non-null T — can be null but no null elements
[T]! non-null list — empty list is OK; null is not
[T!]! non-null list of non-null T — must be a non-empty-allowed list with no null elements

Built-in scalars

Scalar Maps to
Int 32-bit signed integer
Float double-precision float
String UTF-8
Boolean true / false
ID opaque string identifier (often UUID or DB primary key)

Custom scalars

scalar DateTime
scalar JSON
scalar URL
scalar UUID

The schema declares them; resolvers handle serialization. Most libraries provide common scalars (DateTime, UUID, EmailAddress) — install rather than reinvent.

In Strawberry:

import datetime
import strawberry
from strawberry.scalars import JSON

@strawberry.type
class Event:
    id: strawberry.ID
    occurred_at: datetime.datetime    # built-in mapping to DateTime scalar
    metadata: JSON                     # arbitrary JSON

Enums

enum Role {
  ADMIN
  EDITOR
  VIEWER
}

Enums are exhaustive at the schema level — adding a value is a breaking change for clients that exhaustively switch on them.

In Strawberry:

import enum
import strawberry

@strawberry.enum
class Role(enum.Enum):
    ADMIN = "ADMIN"
    EDITOR = "EDITOR"
    VIEWER = "VIEWER"

Input types

For mutation arguments, GraphQL distinguishes object types from inputs:

input CreateUserInput {
  name: String!
  email: String!
  role: Role = VIEWER          # default value
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

Input types can only contain scalars, enums, and other inputs. No nested object-type fields, no fields with arguments. This prevents inputs from accidentally being resolvable.

Interfaces

interface Node {
  id: ID!
}

type User implements Node {
  id: ID!
  name: String!
}

type Post implements Node {
  id: ID!
  title: String!
}

type Query {
  node(id: ID!): Node
}

Interfaces let you have a common interface implemented by multiple types. The Node interface is the Relay convention for “anything with an ID.”

Clients can query the interface and ask for type-specific fields:

query {
  node(id: "user-42") {
    id
    ... on User { name }
    ... on Post { title }
  }
}

... on TypeName is an “inline fragment” — narrows to type-specific fields.

Unions

union SearchResult = User | Post | Comment

type Query {
  search(query: String!): [SearchResult!]!
}

Unions are like interfaces but without shared fields — types just appear in the same list.

query {
  search(query: "alice") {
    __typename
    ... on User { name email }
    ... on Post { title }
    ... on Comment { body }
  }
}

__typename is a built-in meta-field returning the concrete type — essential for clients to know which case they got.

Arguments and default values

type Query {
  users(limit: Int = 20, offset: Int = 0, role: Role): [User!]!
}

Arguments can have defaults. Required args (Int!) without defaults must be provided.

Directives

Annotations on schema elements that modify behavior. Built-in:

type User {
  id: ID!
  oldField: String @deprecated(reason: "Use newField")
  newField: String
}

Query-side:

query GetUser($includeEmail: Boolean!) {
  user(id: 42) {
    name
    email @include(if: $includeEmail)
    oldName @deprecated
  }
}

Built-in directives: @include, @skip, @deprecated, @specifiedBy. Custom directives let you add auth, complexity hints, etc.:

type Query {
  adminPanel: AdminInfo! @auth(role: ADMIN)
}

The server-side library handles execution; the directive declaration is in the schema.

Operation types (root types)

schema {
  query: Query
  mutation: Mutation
  subscription: Subscription
}

type Query {
  user(id: ID!): User
  users: [User!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

type Subscription {
  userCreated: User!
}

Query, Mutation, Subscription are the entry points. The schema definition is technically optional if you use these standard names.

Schema-first vs code-first

Two ways to build a schema:

Approach How
Schema-first write .graphql SDL files; generate code from them
Code-first define types in Python (Strawberry, Graphene); SDL is generated
Pros Cons
Schema-first language-agnostic schema, single source of truth manual binding to types, types defined twice
Code-first type checker sees types, no double definition SDL is generated (review noise)

Modern Python: code-first with Strawberry (uses dataclass-like syntax + typing). For polyglot teams sharing one schema: schema-first.

# Code-first (Strawberry)
@strawberry.type
class User:
    id: strawberry.ID
    name: str
    posts: list["Post"]
# Schema-first (.graphql file)
type User {
  id: ID!
  name: String!
  posts: [Post!]!
}
# + Python code that binds resolvers to these types

Introspection

GraphQL has built-in introspection — clients can query the schema itself:

query {
  __schema {
    types {
      name
      fields { name type { name } }
    }
  }
}

Powers tools like GraphiQL (the in-browser query editor) and Apollo’s codegen. Built-in features include __type(name: "User") and __typename.

Disable introspection in production for “private” APIs to make schema discovery harder. It’s not real security (the schema is in your client code), but reduces casual reconnaissance.

# Strawberry: disable in production
schema = strawberry.Schema(query=Query, config=StrawberryConfig(disable_introspection=True))

Schema evolution

Additive changes are safe:

  • Add a new field.
  • Add a new type.
  • Add a new enum value (careful: clients may exhaustively switch).
  • Add an optional argument.

Breaking changes:

  • Remove a field.
  • Change a field’s type (StringInt).
  • Make an existing optional argument required.
  • Make a non-null field nullable (clients that depended on non-null break).

For deprecations:

type User {
  fullName: String! @deprecated(reason: "Use firstName and lastName")
  firstName: String!
  lastName: String!
}

Tools like Apollo Studio track which fields are used; you can remove deprecated fields safely once usage drops to zero. See 06_errors_versioning.md.

Common pitfalls

  • Mutable defaults in input types — defaults in SDL are evaluated by the library; treat as the spec describes (literal values).
  • [T] vs [T!] — non-null modifier on list element vs list itself. Get them wrong, clients see unexpected nulls.
  • One giant query typeQuery ends up with 100 fields. Use namespacing: Query.user(...), Query.admin: AdminQueries! with AdminQueries.users: [User!]!.
  • Introspection enabled in production for a private API — clients can pull the full schema (not “security” but information leak).
  • Custom scalars without serialization testsDateTime rendering differently across timezones is a common bug.

Common interview confusions

  • “Interfaces and unions are the same.” — interfaces have shared fields all implementers must have. Unions just group unrelated types.
  • Int! is the same as Int.”! means non-null. Int! cannot be null; Int can be.
  • “You declare resolvers in the schema.” — schema is types only. Resolvers are separate code that bind to fields.

Interview angle

  • “What’s the GraphQL schema?” — typed contract written in SDL: object types, fields, scalars, enums, inputs, queries, mutations, subscriptions. The source of truth that defines what clients can request.
  • “Difference between object types and input types?” — object types are responses (can have fields with arguments and resolvers). Input types are mutation arguments (only scalars/enums/other inputs; no resolvable fields).
  • “What’s ! in the schema?” — non-null modifier. String! means the field cannot be null. [String!]! means non-null list of non-null strings.
  • “Schema-first vs code-first?” — schema-first writes .graphql SDL and binds code to it (good for polyglot teams). Code-first defines types in Python/TS code and generates SDL (better for single-language teams with strong type checkers).
  • “What’s introspection and should it be enabled in production?” — built-in query (__schema) returning the schema itself. Enable in dev for tooling; disable for “private” production APIs to reduce casual schema discovery (it’s not real security).
  • “How do you handle breaking changes in GraphQL?” — mark fields @deprecated, monitor usage, remove when traffic drops to zero. Non-breaking additions (new fields/types/optional args) are free. There’s no version bump in GraphQL; the schema evolves additively.