Building a GraphQL API with Node.js and TypeScript
TypeScript brings type safety to GraphQL APIs. After building production GraphQL APIs with TypeScript, here’s how to set it up effectively.
Project Setup
Dependencies
npm install apollo-server-express express graphql
npm install -D typescript @types/node @types/express ts-node nodemon
npm install -D @graphql-codegen/cli @graphql-codegen/typescript \
@graphql-codegen/typescript-resolvers
TypeScript Configuration
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Schema Definition
Schema-First Approach
# src/schema/schema.graphql
type Query {
users: [User!]!
user(id: ID!): User
posts: [Post!]!
post(id: ID!): Post
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
updatedAt: String!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
createdAt: String!
}
input CreateUserInput {
name: String!
email: String!
}
input UpdateUserInput {
name: String
email: String
}
Code Generation
Codegen Configuration
# codegen.yml
schema: src/schema/schema.graphql
generates:
src/generated/types.ts:
plugins:
- typescript
- typescript-resolvers
config:
useIndexSignature: true
contextType: ../context#Context
Generate Types
npm run codegen
# Generates src/generated/types.ts
Type-Safe Resolvers
// src/resolvers/user.ts
import { Resolvers } from '../generated/types';
import { Context } from '../context';
import { UserService } from '../services/userService';
export const userResolvers: Resolvers<Context> = {
Query: {
users: async (parent, args, context) => {
return await context.userService.getAllUsers();
},
user: async (parent, args, context) => {
return await context.userService.getUserById(args.id);
}
},
Mutation: {
createUser: async (parent, args, context) => {
return await context.userService.createUser(args.input);
},
updateUser: async (parent, args, context) => {
return await context.userService.updateUser(args.id, args.input);
},
deleteUser: async (parent, args, context) => {
await context.userService.deleteUser(args.id);
return true;
}
},
User: {
posts: async (parent, args, context) => {
return await context.postService.getPostsByUserId(parent.id);
}
}
};
Context Type
// src/context.ts
import { UserService } from './services/userService';
import { PostService } from './services/postService';
export interface Context {
userService: UserService;
postService: PostService;
userId?: string;
}
export function createContext(): Context {
return {
userService: new UserService(),
postService: new PostService()
};
}
Service Layer
// src/services/userService.ts
import { User, CreateUserInput, UpdateUserInput } from '../generated/types';
import { db } from '../db';
export class UserService {
async getAllUsers(): Promise<User[]> {
return await db.users.findMany();
}
async getUserById(id: string): Promise<User | null> {
return await db.users.findUnique({ where: { id } });
}
async createUser(input: CreateUserInput): Promise<User> {
return await db.users.create({
data: {
name: input.name,
email: input.email
}
});
}
async updateUser(id: string, input: UpdateUserInput): Promise<User> {
return await db.users.update({
where: { id },
data: {
...(input.name && { name: input.name }),
...(input.email && { email: input.email })
}
});
}
async deleteUser(id: string): Promise<void> {
await db.users.delete({ where: { id } });
}
}
Apollo Server Setup
// src/server.ts
import express from 'express';
import { ApolloServer } from 'apollo-server-express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { resolvers } from './resolvers';
import { createContext } from './context';
const typeDefs = readFileSync(
join(__dirname, 'schema/schema.graphql'),
'utf-8'
);
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => {
const context = createContext();
// Extract user from token
const token = req.headers.authorization?.replace('Bearer ', '');
if (token) {
context.userId = verifyToken(token);
}
return context;
},
formatError: (error) => {
console.error(error);
return {
message: error.message,
code: error.extensions?.code
};
}
});
const app = express();
async function start() {
await server.start();
server.applyMiddleware({ app, path: '/graphql' });
app.listen(4000, () => {
console.log(`Server ready at http://localhost:4000${server.graphqlPath}`);
});
}
start();
Resolvers Index
// src/resolvers/index.ts
import { Resolvers } from '../generated/types';
import { userResolvers } from './user';
import { postResolvers } from './post';
export const resolvers: Resolvers = {
Query: {
...userResolvers.Query,
...postResolvers.Query
},
Mutation: {
...userResolvers.Mutation,
...postResolvers.Mutation
},
User: userResolvers.User,
Post: postResolvers.Post
};
DataLoader for N+1
// src/loaders/userLoader.ts
import DataLoader from 'dataloader';
import { User } from '../generated/types';
import { db } from '../db';
export function createUserLoader() {
return new DataLoader<string, User>(async (userIds) => {
const users = await db.users.findMany({
where: { id: { in: userIds } }
});
const userMap = new Map(users.map(user => [user.id, user]));
return userIds.map(id => userMap.get(id) || null);
});
}
// Add to context
export interface Context {
userService: UserService;
postService: PostService;
userLoader: ReturnType<typeof createUserLoader>;
userId?: string;
}
// Use in resolver
User: {
posts: async (parent, args, context) => {
// DataLoader batches requests
return await context.postLoader.loadMany(parent.postIds);
}
}
Testing
// src/__tests__/user.test.ts
import { ApolloServer } from 'apollo-server';
import { typeDefs } from '../schema';
import { resolvers } from '../resolvers';
const server = new ApolloServer({
typeDefs,
resolvers,
context: createMockContext()
});
describe('User queries', () => {
it('should fetch all users', async () => {
const query = `
query {
users {
id
name
email
}
}
`;
const result = await server.executeOperation({ query });
expect(result.errors).toBeUndefined();
expect(result.data?.users).toBeDefined();
});
});
Error Handling
// src/errors.ts
export class UserNotFoundError extends Error {
constructor(id: string) {
super(`User with id ${id} not found`);
this.name = 'UserNotFoundError';
}
}
// In resolver
user: async (parent, args, context) => {
const user = await context.userService.getUserById(args.id);
if (!user) {
throw new UserNotFoundError(args.id);
}
return user;
}
Validation
// src/validators/userValidator.ts
import { CreateUserInput } from '../generated/types';
export function validateCreateUserInput(input: CreateUserInput): void {
if (!input.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
throw new Error('Invalid email format');
}
if (input.name.length < 2) {
throw new Error('Name must be at least 2 characters');
}
}
// In resolver
createUser: async (parent, args, context) => {
validateCreateUserInput(args.input);
return await context.userService.createUser(args.input);
}
Best Practices
- Use code generation - Type-safe resolvers
- Separate concerns - Services, resolvers, loaders
- Use DataLoader - Prevent N+1 queries
- Validate inputs - Before processing
- Handle errors - Proper error types
- Test resolvers - Unit and integration tests
- Use context - Dependency injection
- Monitor performance - Track resolver performance
Conclusion
TypeScript + GraphQL enables:
- Type-safe resolvers
- Better developer experience
- Catch errors at compile time
- Auto-completion in IDEs
Start with schema-first, generate types, and build type-safe resolvers. The patterns shown here work for production APIs.
GraphQL API with Node.js and TypeScript from November 2018, covering Apollo Server 2.0+ and code generation.