Backend / Protocols / GraphQL / 02_schema_and_types.md

GraphQL Schema and Types

Updated 6 interview angles 7 min read source
On this page16
  1. Core building blocks
  2. Built-in scalars
  3. Custom scalars
  4. Enums
  5. Input types
  6. Interfaces
  7. Unions
  8. Arguments and default values
  9. Directives
  10. Operation types (root types)
  11. Schema-first vs code-first
  12. Introspection
  13. Schema evolution
  14. Common pitfalls
  15. Common interview confusions
  16. Interview angle

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

graphql
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

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

python
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

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

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

graphql
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

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

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

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

Unions

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

graphql
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

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

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

Query-side:

graphql
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.:

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

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

Operation types (root types)

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

python
# Code-first (Strawberry)
@strawberry.type
class User:
    id: strawberry.ID
    name: str
    posts: list["Post"]
graphql
# 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:

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

python
# 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:

graphql
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 GraphQL Errors and Versioning.

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 6

  • “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.