The GraphQL Type System
The type system is the foundation of every GraphQL API. It defines what data can be queried, what shape it takes, and what operations are available. Every GraphQL service defines a set of types that completely describe the set of possible data you can query on that service.
When queries come in, they are validated and executed against the schema. This strong typing enables powerful developer tooling like autocompletion, documentation generation, and compile-time error checking.
Scalar Types
Scalars are the leaf nodes of a GraphQL query — they resolve to concrete data. GraphQL comes with five built-in scalar types:
| Type | Description | Example |
|---|---|---|
| Int | Signed 32-bit integer | 42 |
| Float | Double-precision floating point | 3.14 |
| String | UTF-8 character sequence | "hello" |
| Boolean | true or false | true |
| ID | Unique identifier (serialized as String) | "abc-123" |
Custom Scalar Types
You can define custom scalars for domain-specific data like dates, URLs, or email addresses:
# Schema definition
scalar DateTime
scalar Email
scalar URL
type Event {
id: ID!
name: String!
date: DateTime!
website: URL
organizer: Email!
}
import { GraphQLScalarType, Kind } from 'graphql';
const DateTimeScalar = new GraphQLScalarType({
name: 'DateTime',
description: 'A date-time string in ISO 8601 format',
serialize(value: unknown): string {
// Sent to the client
if (value instanceof Date) {
return value.toISOString();
}
throw new Error('DateTime cannot represent non-Date type');
},
parseValue(value: unknown): Date {
// Received from the client as a variable
if (typeof value === 'string') {
const date = new Date(value);
if (isNaN(date.getTime())) {
throw new Error('DateTime cannot represent invalid date string');
}
return date;
}
throw new Error('DateTime must be a string');
},
parseLiteral(ast): Date {
// Received from the client inline in the query
if (ast.kind === Kind.STRING) {
return new Date(ast.value);
}
throw new Error('DateTime must be a string');
},
});
Object Types
Object types are the most common type in a GraphQL schema. They represent a collection of fields, where each field has its own type:
type User {
id: ID!
username: String!
email: String!
bio: String
avatarUrl: URL
posts: [Post!]!
followers: [User!]!
followersCount: Int!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
body: String!
excerpt: String
author: User!
tags: [Tag!]!
comments: [Comment!]!
likesCount: Int!
isPublished: Boolean!
publishedAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
}
type Tag {
id: ID!
name: String!
slug: String!
posts: [Post!]!
}
Non-Null and List Modifiers
- String — nullable string (can be null)
- String! — non-null string (guaranteed to have a value)
- [String] — nullable list of nullable strings
- [String!] — nullable list of non-null strings
- [String!]! — non-null list of non-null strings (always returns an array, items are never null)
Enum Types
Enums define a type that is restricted to a particular set of allowed values. They are useful for status fields, roles, categories, and any field with a finite set of options:
enum UserRole {
ADMIN
MODERATOR
EDITOR
VIEWER
}
enum PostStatus {
DRAFT
REVIEW
PUBLISHED
ARCHIVED
}
enum SortOrder {
ASC
DESC
}
type User {
id: ID!
name: String!
role: UserRole!
}
type Query {
posts(status: PostStatus, sortBy: SortOrder): [Post!]!
}
Interface Types
Interfaces define a set of fields that multiple types must include. They enable polymorphism, allowing you to query for a common set of fields regardless of the concrete type:
interface Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
}
interface Likeable {
likesCount: Int!
isLikedByViewer: Boolean!
}
type Post implements Node & Likeable {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
likesCount: Int!
isLikedByViewer: Boolean!
title: String!
body: String!
author: User!
}
type Comment implements Node & Likeable {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
likesCount: Int!
isLikedByViewer: Boolean!
text: String!
author: User!
post: Post!
}
Union Types
Unions are similar to interfaces but do not require any shared fields. They represent a value that could be one of several different object types. Unions are ideal for search results, activity feeds, and polymorphic relationships:
union SearchResult = User | Post | Tag | Comment
type Query {
search(query: String!): [SearchResult!]!
}
# Querying unions requires inline fragments
query SearchExample {
search(query: "graphql") {
... on User {
id
username
avatarUrl
}
... on Post {
id
title
excerpt
author { username }
}
... on Tag {
id
name
slug
}
... on Comment {
id
text
author { username }
}
}
}
Input Types
Input types are special object types used for mutation arguments. They cannot have fields that resolve to other object types — only scalars, enums, and other input types. This separation ensures clarity between what clients send and what the server returns:
input CreateUserInput {
username: String!
email: String!
bio: String
role: UserRole = VIEWER
}
input UpdateUserInput {
username: String
email: String
bio: String
role: UserRole
}
input PostFilterInput {
status: PostStatus
authorId: ID
tags: [String!]
search: String
dateRange: DateRangeInput
}
input DateRangeInput {
from: DateTime!
to: DateTime!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
}
type Query {
posts(filter: PostFilterInput, limit: Int = 20, offset: Int = 0): [Post!]!
}
Input Type Best Practices
- Use Input suffix: Name your input types with an
Inputsuffix for clarity (e.g.,CreateUserInput) - Separate create/update inputs: Create inputs have required fields; update inputs have all optional fields
- Wrap in a single argument: Use
input: CreateUserInput!instead of many individual arguments - Default values: Provide sensible defaults like
role: UserRole = VIEWER
Type System in TypeScript Resolvers
When building resolvers in TypeScript, you want your types to match the schema. Here is how to define TypeScript types that mirror your GraphQL schema:
// Types matching the GraphQL schema
interface User {
id: string;
username: string;
email: string;
bio: string | null;
role: 'ADMIN' | 'MODERATOR' | 'EDITOR' | 'VIEWER';
createdAt: Date;
updatedAt: Date;
}
interface Post {
id: string;
title: string;
body: string;
excerpt: string | null;
authorId: string; // foreign key, resolved to User by resolver
isPublished: boolean;
publishedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
// Resolver type definitions
import { GraphQLResolveInfo } from 'graphql';
type Context = {
db: Database;
userId: string | null;
};
type Resolvers = {
Query: {
user: (parent: unknown, args: { id: string }, ctx: Context, info: GraphQLResolveInfo) => Promise<User | null>;
posts: (parent: unknown, args: { filter?: PostFilter; limit?: number; offset?: number }, ctx: Context) => Promise<Post[]>;
};
User: {
posts: (parent: User, args: unknown, ctx: Context) => Promise<Post[]>;
followersCount: (parent: User, args: unknown, ctx: Context) => Promise<number>;
};
Post: {
author: (parent: Post, args: unknown, ctx: Context) => Promise<User>;
};
};
Key Takeaways
- The type system is the contract — it defines every possible interaction with your API
- Use non-null wisely — only mark fields as non-null if you can always guarantee a value
- Interfaces share common fields — use them when types share behavior
- Unions handle polymorphism — use them when types do not share fields
- Input types separate concerns — keep input shapes distinct from output types