Loading learning content...
In GraphQL, the schema is not merely documentation—it is the executable contract that governs every interaction between clients and servers. Unlike REST APIs where the contract is often implicit or externally documented in OpenAPI specifications, a GraphQL schema is the authoritative source of truth, validated at runtime and leveraged by tooling at build time.
The schema answers three fundamental questions:
Designing a good schema is one of the most critical decisions in GraphQL architecture. A well-designed schema enables clients to express their needs efficiently, evolves without breaking changes, and encodes domain knowledge that guides correct API usage.
By the end of this page, you will master the GraphQL type system—scalars, objects, interfaces, unions, enums, and input types. You'll understand nullability semantics, learn schema design principles that prevent common pitfalls, and develop intuition for designing schemas that evolve gracefully over years of production use.
GraphQL's type system is the foundation upon which all queries are built. Every piece of data that flows through a GraphQL API has a type, and the schema defines the complete universe of possible types.
Type Categories in GraphQL:
Every type (except Input Objects) can be used as output types, while only Scalars, Enums, and Input Objects can be used as input types. This distinction prevents recursive complexity in arguments.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
# ============================================# SCALAR TYPES# ============================================ # Built-in scalars# String: UTF-8 character sequence# Int: 32-bit signed integer# Float: Double-precision floating-point# Boolean: true or false# ID: Unique identifier (serialized as String) # Custom scalar definitionsscalar DateTime # ISO-8601 datetimescalar Email # Validated email addressscalar URL # Validated URLscalar JSON # Arbitrary JSON (escape hatch, use sparingly)scalar PositiveInt # Integer > 0scalar BigInt # Arbitrary precision integer # ============================================# ENUM TYPES# ============================================ enum UserRole { ADMIN MODERATOR MEMBER GUEST} enum PostStatus { DRAFT PENDING_REVIEW PUBLISHED @deprecated(reason: "Use LIVE instead") LIVE ARCHIVED} enum SortOrder { ASC DESC} # ============================================# OBJECT TYPES# ============================================ type User { # Fields with scalar return types id: ID! email: Email! name: String! bio: String # Nullable field avatarUrl: URL createdAt: DateTime! # Field with enum return type role: UserRole! # Field with arguments posts( first: Int = 10 after: String status: PostStatus orderBy: PostOrderBy ): PostConnection! # Computed/derived fields fullName: String! # May be computed from firstName + lastName isVerified: Boolean! # May involve complex business logic # Relationship fields followers: UserConnection! following: UserConnection! organization: Organization # Nullable: user may not belong to org} type Post { id: ID! title: String! slug: String! content: String! excerpt: String status: PostStatus! publishedAt: DateTime createdAt: DateTime! updatedAt: DateTime! # Relationships author: User! category: Category tags: [Tag!]! # Non-null list of non-null items comments(first: Int, after: String): CommentConnection! # Metrics (may be computed or cached) viewCount: Int! likeCount: Int!} # ============================================# INTERFACE TYPES# ============================================ interface Node { """Global unique identifier for the object.""" id: ID!} interface Timestamped { createdAt: DateTime! updatedAt: DateTime!} interface Authored { author: User!} # Types implementing interfacestype Post implements Node & Timestamped & Authored { id: ID! createdAt: DateTime! updatedAt: DateTime! author: User! # ... other Post fields} type Comment implements Node & Timestamped & Authored { id: ID! createdAt: DateTime! updatedAt: DateTime! author: User! body: String! post: Post!} # ============================================# UNION TYPES# ============================================ union SearchResult = User | Post | Category | Tag union FeedItem = Post | SharedPost | Poll | Article union NotificationTarget = Post | Comment | User type Notification implements Node { id: ID! message: String! target: NotificationTarget! # Query with inline fragments createdAt: DateTime!} # ============================================# INPUT OBJECT TYPES# ============================================ input CreatePostInput { title: String! content: String! categoryId: ID tagIds: [ID!] status: PostStatus = DRAFT publishAt: DateTime # Schedule for later} input UpdatePostInput { title: String content: String categoryId: ID tagIds: [ID!] status: PostStatus} input PostFilters { authorId: ID categoryId: ID status: PostStatus publishedAfter: DateTime publishedBefore: DateTime search: String} input PostOrderBy { field: PostOrderField! direction: SortOrder!} enum PostOrderField { CREATED_AT PUBLISHED_AT TITLE VIEW_COUNT LIKE_COUNT}Design types to represent domain concepts, not database tables. A User type might draw data from multiple tables (users, profiles, preferences) and compute fields dynamically (followerCount, isOnline). The schema models the domain as clients understand it, not as the database stores it.
GraphQL's nullability system is one of its most nuanced features. Every field is nullable by default; the ! operator marks fields as non-null. This inversion from many programming languages has profound implications for schema design and error handling.
The Nullability Rules:
type Example {
nullable: String # Can be null
required: String! # Cannot be null
nullableList: [String] # List can be null, items can be null
requiredList: [String]! # List cannot be null, items can be null
strictList: [String!]! # List cannot be null, items cannot be null
}
| Type Signature | List Null? | Items Null? | Example Valid Values |
|---|---|---|---|
| [String] | Yes | Yes | null, [], [""], [null], ["a", null] |
| [String]! | No | Yes | [], [""], [null], ["a", null] |
| [String!] | Yes | No | null, [], [""], ["a", "b"] |
| [String!]! | No | No | [], [""], ["a", "b"] |
The Error Propagation Rule:
When a non-null field resolves to null (due to an error), GraphQL propagates null up the tree until it reaches a nullable field. This can cause entire branches of data to become null.
Example of Null Propagation:
query {
user(id: "1") { # Nullable
name # Non-null
posts { # Non-null
edges { # Non-null
node { # Nullable
title # Non-null
author { # Non-null
name # Non-null - if this fails, propagates up
}
}
}
}
}
}
If author.name fails and returns null on a non-null field, the error propagates:
author becomes null → but author is non-nullnode becomes null → node is nullable ✓ (propagation stops here)The individual edge's node becomes null, but other edges remain intact.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// PRINCIPLE 1: Make fields non-null unless null has semantic meaning // ❌ Wrong: Unnecessary nullabilitytype User { id: ID // IDs should never be null name: String // Does null mean empty or missing? createdAt: DateTime // Timestamp should always exist} // ✅ Right: Intentional nullabilitytype User { id: ID! name: String! createdAt: DateTime! bio: String // Nullable: user may not have set bio deletedAt: DateTime // Nullable: null means not deleted} // PRINCIPLE 2: Consider error isolation for relationship fields // Aggressive non-null (entire user disappears if posts fail)type User { posts: [Post!]! // If post fetching fails, error propagates to user} // Defensive nullability (posts can fail independently)type User { posts: [Post] // Individual posts can be null, list can be null} // Best practice: Use connection types with proper nullabilitytype User { posts(first: Int, after: String): PostConnection!} type PostConnection { edges: [PostEdge!]! // List always present pageInfo: PageInfo! totalCount: Int!} type PostEdge { cursor: String! node: Post // Node is nullable - individual failures isolated} // PRINCIPLE 3: Lists should almost always be non-null // ❌ Ambiguous: Is null different from empty array?type User { roles: [Role]} // ✅ Clear: Empty array means "no roles"type User { roles: [Role!]!} // PRINCIPLE 4: Input types have different considerations // For mutations, required fields should be non-nullinput CreateUserInput { email: Email! // Required to create user name: String! // Required to create user bio: String // Optional} // For updates, most fields should be nullable (patch semantics)input UpdateUserInput { name: String // Null means "don't update" bio: String # Null means "don't update" # To explicitly clear bio, use a separate input pattern: clearBio: Boolean}Once you mark a field as non-null, removing the ! is a breaking change—existing clients expect a value. Start nullable and add ! later if you're unsure. It's easier to strengthen guarantees than to weaken them.
Interfaces and Unions enable polymorphism in GraphQL, allowing fields to return different object types. Understanding when to use each is crucial for expressive schema design.
Interface vs Union:
Decision Framework:
| Use Case | Choose | Reason |
|---|---|---|
| Share common fields across types | Interface | Avoid field duplication |
| Types have no structural similarity | Union | Force inline fragment usage |
| Need guaranteed fields on result | Interface | Common fields always available |
| Future types may vary significantly | Union | No structural commitment |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
# ============================================# THE NODE INTERFACE (Relay Specification)# ============================================ """An object with a globally unique ID.Enables refetching any object by ID."""interface Node { """ The globally unique identifier for the object. Node IDs are opaque strings, often base64-encoded. Example: "VXNlcjoxMjM=" (base64 of "User:123") """ id: ID!} # Enable global refetchingtype Query { """Fetches an object given its global ID.""" node(id: ID!): Node """Fetches objects given their global IDs.""" nodes(ids: [ID!]!): [Node]!} # All types implement Node for consistent ID handlingtype User implements Node { id: ID! # ...} type Post implements Node { id: ID! # ...} # ============================================# INTERFACE INHERITANCE PATTERNS# ============================================ interface Error { message: String! code: ErrorCode!} interface FieldError implements Error { message: String! code: ErrorCode! field: String! # Additional field path: [String!]! # Path to the field} type ValidationError implements FieldError & Error { message: String! code: ErrorCode! field: String! path: [String!]! constraint: String! # Type-specific field} type AuthorizationError implements Error { message: String! code: ErrorCode! requiredPermission: Permission!} # ============================================# UNION TYPES FOR HETEROGENEOUS RESULTS# ============================================ union SearchResult = User | Post | Comment | Tag | Category type Query { search(query: String!, types: [SearchType!]): [SearchResult!]!} # Querying unions requires inline fragmentsquery Search($query: String!) { search(query: $query) { # No common fields - must use fragments ... on User { id name avatarUrl } ... on Post { id title excerpt author { name } } ... on Comment { id body post { title } } }} # ============================================# RESULT TYPES (Union for Success/Error)# ============================================ type CreateUserSuccess { user: User!} type CreateUserError { errors: [FieldError!]!} union CreateUserResult = CreateUserSuccess | CreateUserError type Mutation { createUser(input: CreateUserInput!): CreateUserResult!} # Usage in clientmutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { ... on CreateUserSuccess { user { id name } } ... on CreateUserError { errors { field message code } } }} # ============================================# TYPE CONDITIONS WITH __typename# ============================================ # Every object type has an implicit __typename fieldquery GetFeedWithTypes { feed(first: 10) { edges { node { __typename # Returns "Post", "Photo", "Article", etc. ... on Post { title content } ... on Photo { imageUrl caption } } } }} # Clients use __typename for:# 1. Runtime type discrimination# 2. Cache normalization (Apollo Client's default key)# 3. Fragment matching logicFor mutations, consider returning union types that represent success or various error states. This pattern (CreateUserResult = CreateUserSuccess | CreateUserError) makes error handling explicit in the type system rather than relying on runtime error arrays or HTTP status codes.
The built-in scalars (String, Int, Float, Boolean, ID) are often insufficient to express domain constraints. Custom scalars encode validation logic and serialization rules directly in the type system.
When to Create Custom Scalars:
Custom Scalar Contract:
A custom scalar must define three functions:
serialize(value): Convert internal value to JSON outputparseValue(value): Convert JSON input to internal valueparseLiteral(ast): Convert query literal to internal value123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
import { GraphQLScalarType, Kind, GraphQLError } from 'graphql'; // ============================================// DATETIME SCALAR// ============================================ export const DateTimeScalar = new GraphQLScalarType({ name: 'DateTime', description: 'ISO-8601 formatted date-time string', // Called when sending data to client serialize(value: unknown): string { if (value instanceof Date) { return value.toISOString(); } if (typeof value === 'string') { return new Date(value).toISOString(); } throw new GraphQLError('DateTime cannot serialize non-date value'); }, // Called when parsing variable values from JSON parseValue(value: unknown): Date { if (typeof value !== 'string') { throw new GraphQLError('DateTime must be a string'); } const date = new Date(value); if (isNaN(date.getTime())) { throw new GraphQLError('DateTime invalid format'); } return date; }, // Called when parsing literal values in the query parseLiteral(ast): Date { if (ast.kind !== Kind.STRING) { throw new GraphQLError('DateTime must be a string'); } const date = new Date(ast.value); if (isNaN(date.getTime())) { throw new GraphQLError('DateTime invalid format'); } return date; },}); // ============================================// EMAIL SCALAR WITH VALIDATION// ============================================ const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; export const EmailScalar = new GraphQLScalarType({ name: 'Email', description: 'Validated email address', serialize(value: unknown): string { if (typeof value !== 'string') { throw new GraphQLError('Email must be a string'); } return value.toLowerCase(); }, parseValue(value: unknown): string { if (typeof value !== 'string') { throw new GraphQLError('Email must be a string'); } const email = value.toLowerCase().trim(); if (!EMAIL_REGEX.test(email)) { throw new GraphQLError(`Invalid email format: ${value}`); } return email; }, parseLiteral(ast): string { if (ast.kind !== Kind.STRING) { throw new GraphQLError('Email must be a string'); } const email = ast.value.toLowerCase().trim(); if (!EMAIL_REGEX.test(email)) { throw new GraphQLError(`Invalid email format: ${ast.value}`); } return email; },}); // ============================================// POSITIVE INT SCALAR// ============================================ export const PositiveIntScalar = new GraphQLScalarType({ name: 'PositiveInt', description: 'Positive integer (greater than 0)', serialize(value: unknown): number { if (typeof value !== 'number' || !Number.isInteger(value)) { throw new GraphQLError('PositiveInt must be an integer'); } if (value <= 0) { throw new GraphQLError('PositiveInt must be positive'); } return value; }, parseValue(value: unknown): number { if (typeof value !== 'number' || !Number.isInteger(value)) { throw new GraphQLError('PositiveInt must be an integer'); } if (value <= 0) { throw new GraphQLError('PositiveInt must be positive'); } return value; }, parseLiteral(ast): number { if (ast.kind !== Kind.INT) { throw new GraphQLError('PositiveInt must be an integer'); } const value = parseInt(ast.value, 10); if (value <= 0) { throw new GraphQLError('PositiveInt must be positive'); } return value; },}); // ============================================// JSON SCALAR (Escape Hatch)// ============================================ export const JSONScalar = new GraphQLScalarType({ name: 'JSON', description: 'Arbitrary JSON value. Use sparingly - prefer typed fields.', serialize(value: unknown): unknown { return value; // Pass through }, parseValue(value: unknown): unknown { return value; // Accept any valid JSON }, parseLiteral(ast, variables): unknown { switch (ast.kind) { case Kind.STRING: case Kind.BOOLEAN: return ast.value; case Kind.INT: case Kind.FLOAT: return parseFloat(ast.value); case Kind.OBJECT: return parseObjectLiteral(ast, variables); case Kind.LIST: return ast.values.map(v => parseLiteral(v, variables)); case Kind.NULL: return null; case Kind.VARIABLE: return variables?.[ast.name.value]; default: throw new GraphQLError('Unsupported literal kind'); } },});A JSON scalar accepts any structure, bypassing GraphQL's type safety. Use it only for truly dynamic, unstructured data (user-defined metadata, feature flags). For everything else, model the structure explicitly—that's the entire point of a type system.
Directives are annotations that modify schema behavior or query execution. They're GraphQL's extension mechanism for cross-cutting concerns.
Built-in Directives:
@deprecated(reason: String) — Mark fields/enum values as deprecated@skip(if: Boolean!) — Conditionally skip field in query@include(if: Boolean!) — Conditionally include field in query@specifiedBy(url: String!) — Link scalar specification (added in GraphQL 2021)Custom Directive Categories:
@auth, @cache)@defer, @stream)123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
# ============================================# BUILT-IN DIRECTIVES# ============================================ type User { id: ID! username: String! # Deprecation with migration guidance email: String! @deprecated(reason: "Use emailAddress instead") emailAddress: Email!} enum UserStatus { ACTIVE INACTIVE @deprecated(reason: "Use DEACTIVATED instead") DEACTIVATED SUSPENDED} # Conditional fields in queriesquery GetUser($userId: ID!, $includeEmail: Boolean!, $skipPosts: Boolean!) { user(id: $userId) { id name email @include(if: $includeEmail) posts @skip(if: $skipPosts) { title } }} # ============================================# CUSTOM SCHEMA DIRECTIVES# ============================================ # Define the directivedirective @auth( requires: Role = USER) on OBJECT | FIELD_DEFINITION directive @cacheControl( maxAge: Int! scope: CacheScope = PUBLIC) on OBJECT | FIELD_DEFINITION directive @deprecated( reason: String = "No longer supported") on FIELD_DEFINITION | ENUM_VALUE | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION directive @rateLimit( limit: Int! window: Int! # seconds) on FIELD_DEFINITION enum Role { ADMIN MODERATOR USER GUEST} enum CacheScope { PUBLIC PRIVATE} # Apply directives to schematype Query { # Public, cacheable publicPosts: [Post!]! @cacheControl(maxAge: 300, scope: PUBLIC) # Requires authentication me: User! @auth(requires: USER) @cacheControl(maxAge: 60, scope: PRIVATE) # Admin only, rate limited adminStats: AdminStats! @auth(requires: ADMIN) @rateLimit(limit: 10, window: 60)} type User @auth(requires: USER) { id: ID! name: String! email: Email! @auth(requires: ADMIN) # Only admins see email posts: [Post!]!} # ============================================# CUSTOM EXECUTABLE DIRECTIVES# ============================================ # @defer and @stream (pending GraphQL spec additions)directive @defer( label: String if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT directive @stream( label: String initialCount: Int! = 0 if: Boolean! = true) on FIELD # Usage: Defer expensive computationsquery GetUserProfile($userId: ID!) { user(id: $userId) { id name avatarUrl # Defer expensive recommendation computation ... @defer(label: "recommendations") { recommendedPosts { id title } } # Stream large lists notifications @stream(initialCount: 5) { id message } }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';import { GraphQLSchema, defaultFieldResolver, GraphQLError } from 'graphql'; // ============================================// AUTH DIRECTIVE IMPLEMENTATION// ============================================ interface AuthDirectiveArgs { requires: 'ADMIN' | 'MODERATOR' | 'USER' | 'GUEST';} export function authDirective(directiveName: string) { const roleHierarchy = { GUEST: 0, USER: 1, MODERATOR: 2, ADMIN: 3, }; return { authDirectiveTypeDefs: ` directive @${directiveName}(requires: Role = USER) on OBJECT | FIELD_DEFINITION enum Role { ADMIN MODERATOR USER GUEST } `, authDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => { // Check for directive on field or parent type const fieldDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; const typeConfig = schema.getType(typeName); const typeDirective = typeConfig ? getDirective(schema, typeConfig, directiveName)?.[0] : null; const directive = fieldDirective ?? typeDirective; if (directive) { const { requires } = directive as AuthDirectiveArgs; const requiredRole = roleHierarchy[requires]; const originalResolver = fieldConfig.resolve ?? defaultFieldResolver; fieldConfig.resolve = async (source, args, context, info) => { const userRole = context.user?.role ?? 'GUEST'; const userRoleLevel = roleHierarchy[userRole] ?? 0; if (userRoleLevel < requiredRole) { throw new GraphQLError( `Unauthorized: requires ${requires} role`, { extensions: { code: 'UNAUTHORIZED', requiredRole: requires, userRole: userRole, } } ); } return originalResolver(source, args, context, info); }; } return fieldConfig; }, }), };} // ============================================// CACHE CONTROL DIRECTIVE// ============================================ export function cacheControlDirective(directiveName: string) { return { cacheControlDirectiveTypeDefs: ` directive @${directiveName}( maxAge: Int! scope: CacheScope = PUBLIC ) on OBJECT | FIELD_DEFINITION enum CacheScope { PUBLIC PRIVATE } `, cacheControlDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { const directive = getDirective(schema, fieldConfig, directiveName)?.[0]; if (directive) { const { maxAge, scope } = directive; const originalResolver = fieldConfig.resolve ?? defaultFieldResolver; fieldConfig.resolve = async (source, args, context, info) => { // Set cache hints on context for HTTP layer context.cacheControl = context.cacheControl ?? { maxAge: 0, scope: 'PUBLIC' }; // Take minimum maxAge across all fields context.cacheControl.maxAge = Math.min( context.cacheControl.maxAge || Infinity, maxAge ); // Scope degrades from PUBLIC to PRIVATE if (scope === 'PRIVATE') { context.cacheControl.scope = 'PRIVATE'; } return originalResolver(source, args, context, info); }; } return fieldConfig; }, }), };}Directives encode cross-cutting concerns directly in the schema, making them visible to clients and tooling. Middleware operates at the resolver level but is invisible in the schema. Use directives when the behavior is part of the API contract (auth, caching), middleware for internal concerns (logging, metrics).
A well-designed schema is intuitive, evolvable, and encodes domain knowledge. Here are principles distilled from production GraphQL systems serving billions of requests.
node field returning Node, provide specific fields like user, post. Generic interfaces are for refetching, not primary navigation.status: PUBLISHED), not past actions (wasPublished: true). Actions are mutations.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
# ============================================# PATTERN: Relay Connection Specification# ============================================ # Standard pagination pattern ensures consistencytype Query { posts( first: Int after: String last: Int before: String filter: PostFilter ): PostConnection!} type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int! # Total matching, regardless of pagination} type PostEdge { cursor: String! # Opaque cursor for this edge node: Post! # The actual item} type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String} # ============================================# PATTERN: Semantic Naming# ============================================ # ❌ Poor namingtype Query { getData(type: String!, id: Int!): JSON makeChange(data: JSON!): JSON} # ✅ Semantic namingtype Query { user(id: ID!): User users(filter: UserFilter): UserConnection! post(id: ID!): Post post(slug: String!): Post # Alternate lookup} type Mutation { createUser(input: CreateUserInput!): CreateUserPayload! updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload! publishPost(id: ID!): PublishPostPayload!} # ============================================# PATTERN: Input/Payload Types# ============================================ # Input types group mutation argumentsinput CreatePostInput { title: String! content: String! categoryId: ID tags: [String!]} # Payload types provide structured resultstype CreatePostPayload { post: Post # The created resource userErrors: [UserError!]! # Validation/business errors} type UserError { field: [String!] # Path to field with error message: String! # Human-readable message code: ErrorCode! # Machine-readable code} # ============================================# PATTERN: Field Arguments for Filtering# ============================================ type User { # Avoid: separate fields for each filter combination # posts: [Post!]! # publishedPosts: [Post!]! # draftPosts: [Post!]! # Prefer: single field with filter arguments posts( status: PostStatus first: Int = 10 after: String ): PostConnection!} # ============================================# PATTERN: Avoiding Breaking Changes# ============================================ # Never remove fields - deprecate themtype User { email: String! @deprecated(reason: "Use emailAddress for validated email") emailAddress: Email! # New field with proper type} # Never change non-null to null (or vice versa) on existing fields# Never change argument types# Never remove enum values # Safe additions:# - New nullable fields# - New nullable arguments with defaults# - New enum values (unless clients use exhaustive matching)# - New typesEvery schema decision encodes assumptions about your domain. A non-null field says 'this will always exist.' An enum says 'these are the only valid values.' A connection says 'this list can grow unboundedly.' Make these decisions deliberately, with full awareness of their implications.
Unlike REST APIs that often use URL versioning (/v1/users, /v2/users), GraphQL favors continuous evolution without breaking changes. The schema becomes a living document that grows without fracturing the client ecosystem.
The Evolution Philosophy:
| Change Type | Safety | Notes |
|---|---|---|
| Add nullable field | ✅ Safe | Clients ignore unknown fields |
| Add non-null field | ✅ Safe | Clients that don't request it are unaffected |
| Add nullable argument | ✅ Safe | Must have default value |
| Add required argument | ❌ Breaking | Existing queries fail |
| Add enum value | ⚠️ Caution | Safe unless clients use exhaustive switch |
| Remove field | ❌ Breaking | Existing queries fail |
| Rename field | ❌ Breaking | Equivalent to remove + add |
| Change field type | ❌ Breaking | Even changing Int to Float breaks |
| Nullability: null → non-null | ❌ Breaking | Clients may now receive errors |
| Nullability: non-null → null | ❌ Breaking | Violates client type expectations |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
# ============================================# DEPRECATION LIFECYCLE# ============================================ # Phase 1: Add new field alongside oldtype User { name: String! # Original field fullName: String! # New field with better semantics} # Phase 2: Deprecate old field with reasontype User { name: String! @deprecated(reason: "Use fullName instead - includes proper formatting") fullName: String!} # Phase 3: (After analytics confirm no usage) Consider removal# Only if you control all clients, or after long deprecation period # ============================================# FIELD ARGUMENT EVOLUTION# ============================================ # Original fieldtype Query { posts(first: Int, after: String): PostConnection!} # Safe evolution: add optional argument with defaulttype Query { posts( first: Int = 20 after: String orderBy: PostOrder = CREATED_AT_DESC # New! filter: PostFilter # New! ): PostConnection!} # ============================================# TYPE EVOLUTION# ============================================ # Original typetype Post { author: User!} # Need to support multiple authors? Don't change author, add authorstype Post { author: User! @deprecated(reason: "Use authors field for multi-author support") authors: [User!]! # New field primaryAuthor: User! # Convenience accessor} # ============================================# ENUM EVOLUTION# ============================================ enum PostStatus { DRAFT PENDING PUBLISHED ARCHIVED SCHEDULED # Safe to add # PENDING_REVIEW # Would require deprecation cycle if replacing PENDING} # When adding enum values, document that clients should handle unknown values# Client code should have a default case:# switch (status) {# case 'DRAFT': ...# case 'PUBLISHED': ...# default: // Handle future values gracefully# } # ============================================# SCHEMA REGISTRY AND VALIDATION# ============================================ # Modern GraphQL tooling validates changes against registered schemas:# # $ graphql schema:push --schema ./schema.graphql# # ❌ BREAKING CHANGE DETECTED# # Field 'User.email' was removed# This field is used by 47 operations across 12 clients# Last used: 2024-01-15# # To proceed, use --force (not recommended)# To safe deprecation, use 'graphql field:deprecate User.email "reason"'While continuous evolution is preferred, sometimes version bumps are necessary: fundamental domain model changes, security-breaking changes requiring immediate removal, or when maintaining backward compatibility causes severe complexity. In such cases, consider running parallel schemas (/graphql/v1, /graphql/v2) with planned sunset dates.
The schema is GraphQL's crown jewel—a type-safe contract that enables powerful tooling, prevents entire categories of bugs, and guides API evolution. Let's consolidate the key concepts:
What's Next:
With schema design understood, we now turn to resolvers—the functions that bring schemas to life by fetching data. We'll explore resolver architecture, the N+1 problem and DataLoader, error handling patterns, and performance optimization strategies.
You now understand GraphQL's type system—scalars, objects, interfaces, unions, enums, input types, and directives. You've learned nullability semantics, design principles, and evolution strategies. Next, we'll explore how resolvers implement this schema to fetch and transform data.