GraphQL with TypeScript — Schema, Resolvers, and Apollo Server 2024
Advertisement
Introduction
Why This Matters
GraphQL solves two of REST's most persistent problems: over-fetching and under-fetching. Clients declare exactly what data they need, and the server returns precisely that. For mobile apps with limited bandwidth and frontends that need flexible data shapes, this is transformative.
For TypeScript backend engineers, GraphQL is particularly compelling because the schema serves as the contract between server and client. With graphql-codegen, that schema generates TypeScript types for both resolvers and client queries — eliminating an entire class of API contract bugs.
In 2024, the GraphQL ecosystem has matured significantly. Apollo Server 4, Pothos for code-first schemas, and DataLoader for N+1 prevention give you a solid production stack. Understanding GraphQL deeply also helps you decide when REST or tRPC is the better choice.
Schema Definition
# schema.graphql
type User {
id: ID!
name: String!
email: String!
role: UserRole!
orders: [Order!]!
createdAt: String!
}
enum UserRole {
ADMIN
USER
}
type Order {
id: ID!
total: Float!
status: OrderStatus!
items: [OrderItem!]!
user: User!
createdAt: String!
}
enum OrderStatus {
PENDING
SHIPPED
DELIVERED
CANCELLED
}
type OrderItem {
id: ID!
productId: String!
quantity: Int!
price: Float!
}
type Query {
user(id: ID!): User
users(page: Int, limit: Int): UserPage!
order(id: ID!): Order
}
type UserPage {
items: [User!]!
total: Int!
hasNext: Boolean!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
placeOrder(input: PlaceOrderInput!): Order!
}
input CreateUserInput {
name: String!
email: String!
password: String!
}
input UpdateUserInput {
name: String
email: String
}
input PlaceOrderInput {
items: [OrderItemInput!]!
}
input OrderItemInput {
productId: String!
quantity: Int!
}Apollo Server 4 Setup
// src/graphql/server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { readFileSync } from 'fs';
import { resolvers } from './resolvers';
import { createContext, GraphQLContext } from './context';
const typeDefs = readFileSync('./schema.graphql', 'utf-8');
const server = new ApolloServer<GraphQLContext>({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Log internal errors without exposing them
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
console.error('[GraphQL Error]', error);
return { message: 'Internal server error' };
}
return formattedError;
},
});
await server.start();
app.use(
'/graphql',
expressMiddleware(server, {
context: createContext,
})
);Type-Safe Resolvers
// src/graphql/resolvers/user.resolver.ts
import { GraphQLError } from 'graphql';
import { Resolvers } from '../__generated__/types'; // from graphql-codegen
export const userResolvers: Resolvers = {
Query: {
user: async (_parent, { id }, { db }) => {
const user = await db.users.findById(id);
if (!user) {
throw new GraphQLError(`User ${id} not found`, {
extensions: { code: 'NOT_FOUND' },
});
}
return user;
},
users: async (_parent, { page = 1, limit = 20 }, { db }) => {
const [items, total] = await Promise.all([
db.users.findAll({ skip: (page - 1) * limit, take: limit }),
db.users.count(),
]);
return {
items,
total,
hasNext: page * limit < total,
};
},
},
Mutation: {
createUser: async (_parent, { input }, { db }) => {
const existing = await db.users.findByEmail(input.email);
if (existing) {
throw new GraphQLError('Email already registered', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
return db.users.create(input);
},
},
User: {
// Resolved with DataLoader to avoid N+1
orders: async (user, _args, { loaders }) => {
return loaders.ordersByUserId.load(user.id);
},
},
};Context and Authentication
// src/graphql/context.ts
import { Request, Response } from 'express';
import { verifyToken } from '../auth/jwt';
import { createLoaders } from './loaders';
import { db } from '../database';
export interface GraphQLContext {
userId: string | null;
userRole: 'admin' | 'user' | null;
db: typeof db;
loaders: ReturnType<typeof createLoaders>;
}
export async function createContext({ req }: { req: Request }): Promise<GraphQLContext> {
const authHeader = req.headers.authorization;
let userId: string | null = null;
let userRole: 'admin' | 'user' | null = null;
if (authHeader?.startsWith('Bearer ')) {
try {
const payload = verifyToken(authHeader.slice(7));
userId = payload.userId;
userRole = payload.role;
} catch {
// Invalid token — context has no user
}
}
return {
userId,
userRole,
db,
loaders: createLoaders(db),
};
}DataLoader — Solving N+1
The N+1 problem is the biggest performance trap in GraphQL. DataLoader batches and caches database calls.
// src/graphql/loaders.ts
import DataLoader from 'dataloader';
import { db } from '../database';
export function createLoaders(db: Database) {
return {
// Batch: fetch all orders for a list of userIds in a single query
ordersByUserId: new DataLoader<string, Order[]>(async (userIds) => {
const orders = await db.orders.findByUserIds(Array.from(userIds));
// Map results back to each userId
const orderMap = new Map<string, Order[]>();
for (const order of orders) {
const existing = orderMap.get(order.userId) ?? [];
orderMap.set(order.userId, [...existing, order]);
}
return userIds.map((id) => orderMap.get(id) ?? []);
}),
// Batch: fetch users by ID
userById: new DataLoader<string, User | null>(async (userIds) => {
const users = await db.users.findByIds(Array.from(userIds));
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) ?? null);
}),
};
}Code Generation with graphql-codegen
npm install --save-dev @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers# codegen.yml
schema: ./schema.graphql
generates:
src/graphql/__generated__/types.ts:
plugins:
- typescript
- typescript-resolvers
config:
contextType: '../context#GraphQLContext'
mappers:
User: '../../database/entities#UserEntity'
Order: '../../database/entities#OrderEntity'npx graphql-codegenCommon Mistakes
- Resolving nested fields without DataLoader — causes N+1 queries in production
- Returning raw database entities instead of safe API types — exposes internal fields like password hashes
- Not setting complexity limits — allows nested queries that abuse the resolver tree
- Using
anyfor resolver context instead of a typedGraphQLContextinterface - Forgetting to handle authorization in individual resolvers — schema-level protection is not enough
Best Practices
- Use
graphql-codegento generate resolver types from the schema — no manual type maintenance - Create DataLoaders per-request in context factory — never share them across requests
- Implement query depth and complexity limits using
graphql-depth-limitandgraphql-query-complexity - Use persisted queries in production to prevent arbitrary query abuse
- Keep resolvers thin — delegate business logic to service classes
Key Takeaways
- GraphQL resolvers should be thin — authorization checks, input validation, and business logic belong in services
- DataLoader batches N resolver calls into a single database query per request cycle
- Apollo Server 4's
formatErrorallows logging internal errors without exposing them to clients graphql-codegengenerates TypeScript types from the schema so resolvers are automatically type-checked- The
contextfactory runs per-request — create DataLoader instances here so they are not shared across requests - GraphQL mutations should validate input and return meaningful
GraphQLErrorwithextensions.code - Persisted queries prevent clients from sending arbitrary GraphQL — important for public APIs
- Choose GraphQL when clients need flexible, nested data fetching; choose REST or tRPC for simpler use cases
Advertisement