GraphQL with TypeScript — Schema, Resolvers, and Apollo Server 2024

Sanjeev SharmaSanjeev Sharma
6 min read

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-codegen

Common 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 any for resolver context instead of a typed GraphQLContext interface
  • Forgetting to handle authorization in individual resolvers — schema-level protection is not enough

Best Practices

  • Use graphql-codegen to 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-limit and graphql-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 formatError allows logging internal errors without exposing them to clients
  • graphql-codegen generates TypeScript types from the schema so resolvers are automatically type-checked
  • The context factory runs per-request — create DataLoader instances here so they are not shared across requests
  • GraphQL mutations should validate input and return meaningful GraphQLError with extensions.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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading