GraphQL TypeScript: API Development and Code Generation
GraphQL with TypeScript provides a powerful combination for building type-safe, efficient APIs. This comprehensive guide covers schema design, resolver implementation, code generation, performance optimization, and production deployment.
Why Choose GraphQL with TypeScript?
- Type Safety: End-to-end type safety from schema to client
- Efficient Data Fetching: Request only the data you need
- Single Endpoint: One endpoint for all data operations
- Real-time Features: Built-in subscription support
- Developer Experience: Excellent tooling and introspection
Step 1: Project Setup and Configuration
Set up a comprehensive GraphQL TypeScript development environment:
# Create project directory
mkdir graphql-typescript-api
cd graphql-typescript-api
# Initialize package.json
npm init -y
# Install GraphQL dependencies
npm install graphql apollo-server-express
npm install @apollo/server
npm install graphql-scalars graphql-tools
npm install dataloader
# Install code generation tools
npm install -D @graphql-codegen/cli
npm install -D @graphql-codegen/typescript
npm install -D @graphql-codegen/typescript-resolvers
npm install -D @graphql-codegen/typescript-operations
# Install TypeScript and dependencies
npm install -D typescript @types/node
npm install -D nodemon ts-node concurrently
# Install additional dependencies
npm install express cors helmet
npm install @prisma/client prisma
npm install jsonwebtoken bcryptjs
npm install zod
# Install development tools
npm install -D jest @types/jest ts-jest
npm install -D eslint @typescript-eslint/eslint-plugin
npm install -D prettier
Configure TypeScript for GraphQL development:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "CommonJS",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/types/*": ["src/types/*"],
"@/resolvers/*": ["src/resolvers/*"],
"@/schema/*": ["src/schema/*"],
"@/utils/*": ["src/utils/*"],
"@/services/*": ["src/services/*"],
"@/generated/*": ["src/generated/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
tsconfig.json
Step 2: GraphQL Schema Definition
Create a comprehensive GraphQL schema using schema-first approach:
# Scalars
scalar DateTime
scalar JSON
scalar Upload
scalar EmailAddress
scalar URL
# User Types
type User {
id: ID!
email: EmailAddress!
username: String!
firstName: String!
lastName: String!
fullName: String!
avatar: URL
bio: String
website: URL
location: String
role: UserRole!
status: UserStatus!
isOnline: Boolean!
lastSeenAt: DateTime
emailVerified: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
# Relationships
posts(first: Int, after: String, orderBy: PostOrderBy): PostConnection!
followers(first: Int, after: String): UserConnection!
following(first: Int, after: String): UserConnection!
addresses: [Address!]!
# Counts
postsCount: Int!
followersCount: Int!
followingCount: Int!
}
type Address {
id: ID!
type: AddressType!
firstName: String!
lastName: String!
company: String
street1: String!
street2: String
city: String!
state: String!
zipCode: String!
country: String!
isDefault: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
}
# Post Types
type Post {
id: ID!
title: String!
content: String!
excerpt: String
slug: String!
status: PostStatus!
featuredImage: URL
seoTitle: String
seoDescription: String
viewCount: Int!
likeCount: Int!
commentCount: Int!
publishedAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
# Relationships
author: User!
comments(first: Int, after: String): CommentConnection!
likes(first: Int, after: String): UserConnection!
tags: [Tag!]!
# User-specific fields
isLiked: Boolean!
canEdit: Boolean!
}
type Comment {
id: ID!
content: String!
status: CommentStatus!
likeCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
# Relationships
author: User!
post: Post!
parent: Comment
replies(first: Int, after: String): CommentConnection!
# User-specific fields
isLiked: Boolean!
canEdit: Boolean!
}
type Tag {
id: ID!
name: String!
slug: String!
color: String
postsCount: Int!
# Relationships
posts(first: Int, after: String): PostConnection!
}
# Product Types
type Product {
id: ID!
name: String!
description: String!
slug: String!
price: Float!
originalPrice: Float
currency: String!
sku: String
stock: Int!
images: [URL!]!
status: ProductStatus!
isFeatured: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
# Relationships
category: Category!
brand: Brand
reviews(first: Int, after: String): ReviewConnection!
variants: [ProductVariant!]!
# Computed fields
averageRating: Float
reviewCount: Int!
isInStock: Boolean!
}
type ProductVariant {
id: ID!
name: String!
sku: String
price: Float
stock: Int!
attributes: JSON!
image: URL
isActive: Boolean!
}
type Category {
id: ID!
name: String!
slug: String!
description: String
image: URL
parent: Category
children: [Category!]!
productsCount: Int!
isActive: Boolean!
# Relationships
products(
first: Int
after: String
filters: ProductFilters
): ProductConnection!
}
type Brand {
id: ID!
name: String!
slug: String!
description: String
logo: URL
website: URL
isActive: Boolean!
# Relationships
products(first: Int, after: String): ProductConnection!
}
type Review {
id: ID!
rating: Int!
title: String
content: String!
images: [URL!]!
isVerified: Boolean!
helpfulCount: Int!
status: ReviewStatus!
createdAt: DateTime!
updatedAt: DateTime!
# Relationships
author: User!
product: Product!
# User-specific fields
isHelpful: Boolean!
}
# Order Types
type Order {
id: ID!
orderNumber: String!
status: OrderStatus!
subtotal: Float!
taxAmount: Float!
shippingCost: Float!
totalAmount: Float!
currency: String!
paymentStatus: PaymentStatus!
shippingMethod: String
trackingNumber: String
estimatedDelivery: DateTime
createdAt: DateTime!
updatedAt: DateTime!
# Relationships
customer: User!
items: [OrderItem!]!
shippingAddress: Address
billingAddress: Address
}
type OrderItem {
id: ID!
name: String!
price: Float!
quantity: Int!
totalPrice: Float!
productSnapshot: JSON!
# Relationships
product: Product!
variant: ProductVariant
}
# Notification Types
type Notification {
id: ID!
title: String!
message: String!
type: NotificationType!
isRead: Boolean!
actionUrl: String
createdAt: DateTime!
# Relationships
user: User!
}
# Connection Types (for pagination)
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type CommentConnection {
edges: [CommentEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type CommentEdge {
node: Comment!
cursor: String!
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProductEdge {
node: Product!
cursor: String!
}
type ReviewConnection {
edges: [ReviewEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ReviewEdge {
node: Review!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Enums
enum UserRole {
ADMIN
MODERATOR
USER
GUEST
}
enum UserStatus {
ACTIVE
INACTIVE
SUSPENDED
BANNED
}
enum AddressType {
HOME
WORK
BILLING
SHIPPING
OTHER
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum CommentStatus {
PENDING
APPROVED
REJECTED
SPAM
}
enum ProductStatus {
DRAFT
PUBLISHED
ARCHIVED
OUT_OF_STOCK
}
enum OrderStatus {
PENDING
CONFIRMED
PROCESSING
SHIPPED
DELIVERED
CANCELLED
REFUNDED
}
enum PaymentStatus {
PENDING
COMPLETED
FAILED
CANCELLED
REFUNDED
}
enum ReviewStatus {
PENDING
APPROVED
REJECTED
}
enum NotificationType {
ORDER_UPDATE
MESSAGE
FOLLOW
LIKE
COMMENT
SYSTEM
MARKETING
}
# Input Types
input CreateUserInput {
email: EmailAddress!
username: String!
firstName: String!
lastName: String!
password: String!
phone: String
}
input UpdateUserInput {
username: String
firstName: String
lastName: String
bio: String
website: URL
location: String
}
input CreatePostInput {
title: String!
content: String!
excerpt: String
featuredImage: URL
seoTitle: String
seoDescription: String
tagIds: [ID!]
status: PostStatus = DRAFT
}
input UpdatePostInput {
title: String
content: String
excerpt: String
featuredImage: URL
seoTitle: String
seoDescription: String
tagIds: [ID!]
status: PostStatus
}
input CreateCommentInput {
content: String!
postId: ID!
parentId: ID
}
input UpdateCommentInput {
content: String!
}
input CreateProductInput {
name: String!
description: String!
price: Float!
categoryId: ID!
brandId: ID
sku: String
stock: Int!
images: [URL!]!
status: ProductStatus = DRAFT
}
input UpdateProductInput {
name: String
description: String
price: Float
categoryId: ID
brandId: ID
sku: String
stock: Int
images: [URL!]
status: ProductStatus
}
input CreateReviewInput {
productId: ID!
rating: Int!
title: String
content: String!
images: [URL!]
}
input CreateAddressInput {
type: AddressType!
firstName: String!
lastName: String!
company: String
street1: String!
street2: String
city: String!
state: String!
zipCode: String!
country: String!
isDefault: Boolean = false
}
input UpdateAddressInput {
type: AddressType
firstName: String
lastName: String
company: String
street1: String
street2: String
city: String
state: String
zipCode: String
country: String
isDefault: Boolean
}
# Filter Types
input ProductFilters {
categoryId: ID
brandId: ID
priceRange: PriceRangeInput
inStock: Boolean
isFeatured: Boolean
status: ProductStatus
}
input PriceRangeInput {
min: Float
max: Float
}
# Order By Types
input PostOrderBy {
field: PostOrderField!
direction: OrderDirection!
}
enum PostOrderField {
CREATED_AT
UPDATED_AT
PUBLISHED_AT
TITLE
VIEW_COUNT
LIKE_COUNT
}
enum OrderDirection {
ASC
DESC
}
# Authentication Types
type AuthPayload {
token: String!
refreshToken: String!
user: User!
}
input LoginInput {
email: EmailAddress!
password: String!
}
# Query Type
type Query {
# Authentication
me: User
# Users
user(id: ID, username: String): User
users(
first: Int
after: String
search: String
role: UserRole
): UserConnection!
# Posts
post(id: ID, slug: String): Post
posts(
first: Int
after: String
authorId: ID
status: PostStatus
orderBy: PostOrderBy
): PostConnection!
# Comments
comment(id: ID!): Comment
# Products
product(id: ID, slug: String): Product
products(
first: Int
after: String
filters: ProductFilters
): ProductConnection!
# Categories
category(id: ID, slug: String): Category
categories(parentId: ID): [Category!]!
# Brands
brand(id: ID, slug: String): Brand
brands(first: Int, after: String): [Brand!]!
# Tags
tag(id: ID, slug: String): Tag
tags(search: String): [Tag!]!
# Orders
order(id: ID!): Order
myOrders(first: Int, after: String): [Order!]!
# Notifications
myNotifications(
first: Int
after: String
unreadOnly: Boolean
): [Notification!]!
unreadNotificationsCount: Int!
# Search
search(
query: String!
type: SearchType
first: Int
after: String
): SearchResult!
}
enum SearchType {
ALL
USERS
POSTS
PRODUCTS
}
union SearchResult = UserConnection | PostConnection | ProductConnection
# Mutation Type
type Mutation {
# Authentication
register(input: CreateUserInput!): AuthPayload!
login(input: LoginInput!): AuthPayload!
refreshToken(refreshToken: String!): AuthPayload!
logout: Boolean!
# User Management
updateProfile(input: UpdateUserInput!): User!
changePassword(currentPassword: String!, newPassword: String!): Boolean!
deleteAccount: Boolean!
# User Actions
followUser(userId: ID!): Boolean!
unfollowUser(userId: ID!): Boolean!
# Address Management
createAddress(input: CreateAddressInput!): Address!
updateAddress(id: ID!, input: UpdateAddressInput!): Address!
deleteAddress(id: ID!): Boolean!
# Posts
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!
likePost(postId: ID!): Boolean!
unlikePost(postId: ID!): Boolean!
# Comments
createComment(input: CreateCommentInput!): Comment!
updateComment(id: ID!, input: UpdateCommentInput!): Comment!
deleteComment(id: ID!): Boolean!
likeComment(commentId: ID!): Boolean!
unlikeComment(commentId: ID!): Boolean!
# Products
createProduct(input: CreateProductInput!): Product!
updateProduct(id: ID!, input: UpdateProductInput!): Product!
deleteProduct(id: ID!): Boolean!
# Reviews
createReview(input: CreateReviewInput!): Review!
updateReview(id: ID!, rating: Int, title: String, content: String): Review!
deleteReview(id: ID!): Boolean!
markReviewHelpful(reviewId: ID!): Boolean!
# Notifications
markNotificationRead(id: ID!): Notification!
markAllNotificationsRead: Boolean!
deleteNotification(id: ID!): Boolean!
# File Upload
uploadFile(file: Upload!): URL!
}
# Subscription Type
type Subscription {
# Real-time notifications
notificationAdded(userId: ID!): Notification!
# Post updates
postLiked(postId: ID!): Post!
commentAdded(postId: ID!): Comment!
# User status
userOnlineStatus(userId: ID!): User!
# Order updates
orderStatusChanged(userId: ID!): Order!
}
src/schema/schema.graphql
Step 3: Code Generation Configuration
Set up GraphQL Code Generator for type-safe development:
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
overwrite: true,
schema: "./src/schema/schema.graphql",
documents: "./src/**/*.{ts,tsx,graphql}",
generates: {
// Generate TypeScript types from schema
"./src/generated/graphql.ts": {
plugins: [
"typescript",
"typescript-resolvers",
{
add: {
content: "/* eslint-disable */",
},
},
],
config: {
useIndexSignature: true,
contextType: "../types/context#GraphQLContext",
mappers: {
User: "../types/prisma#UserWithRelations",
Post: "../types/prisma#PostWithRelations",
Product: "../types/prisma#ProductWithRelations",
Order: "../types/prisma#OrderWithRelations",
Comment: "../types/prisma#CommentWithRelations",
Review: "../types/prisma#ReviewWithRelations",
Address: "@prisma/client#Address",
Category: "@prisma/client#Category",
Brand: "@prisma/client#Brand",
Tag: "@prisma/client#Tag",
Notification: "@prisma/client#Notification",
},
scalars: {
DateTime: "Date",
JSON: "any",
Upload: "Promise<FileUpload>",
EmailAddress: "string",
URL: "string",
},
maybeValue: "T | null | undefined",
inputMaybeValue: "T | null | undefined",
enumsAsTypes: true,
constEnums: true,
numericEnums: true,
skipTypename: false,
avoidOptionals: {
field: true,
inputValue: false,
object: false,
},
directiveMap: {
auth: "authDirective",
rateLimit: "rateLimitDirective",
},
},
},
// Generate operations types for client-side
"./src/generated/operations.ts": {
documents: "./src/operations/**/*.graphql",
plugins: [
"typescript",
"typescript-operations",
{
add: {
content: "/* eslint-disable */",
},
},
],
config: {
preResolveTypes: true,
skipTypename: false,
enumsAsTypes: true,
constEnums: true,
maybeValue: "T | null | undefined",
},
},
// Generate React Apollo hooks (if using React)
"./src/generated/react-apollo.ts": {
documents: "./src/operations/**/*.graphql",
plugins: [
"typescript",
"typescript-operations",
"typescript-react-apollo",
{
add: {
content: "/* eslint-disable */",
},
},
],
config: {
withHooks: true,
withHOC: false,
withComponent: false,
apolloReactHooksImportFrom: "@apollo/client",
},
},
// Generate fragment matcher
"./src/generated/fragment-matcher.json": {
plugins: ["fragment-matcher"],
},
// Generate introspection result
"./src/generated/introspection.json": {
plugins: ["introspection"],
},
},
hooks: {
afterAllFileWrite: ["prettier --write"],
},
};
export default config;
codegen.ts
Step 4: Resolver Implementation
Create comprehensive GraphQL resolvers with type safety:
import {
AuthenticationError,
ForbiddenError,
UserInputError,
} from "apollo-server-express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { z } from "zod";
import {
Resolvers,
User,
CreateUserInput,
UpdateUserInput,
LoginInput,
AuthPayload,
UserConnection,
MutationResolvers,
QueryResolvers,
UserResolvers as UserFieldResolvers,
} from "@/generated/graphql";
import { GraphQLContext } from "@/types/context";
import { UserWithRelations } from "@/types/prisma";
import { validateInput } from "@/utils/validation";
import { createCursor, parseCursor } from "@/utils/pagination";
// Validation schemas
const createUserSchema = z.object({
email: z.string().email(),
username: z.string().min(3).max(50),
firstName: z.string().min(1).max(100),
lastName: z.string().min(1).max(100),
password: z.string().min(8),
phone: z.string().optional(),
});
const updateUserSchema = z.object({
username: z.string().min(3).max(50).optional(),
firstName: z.string().min(1).max(100).optional(),
lastName: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
website: z.string().url().optional(),
location: z.string().max(100).optional(),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
// Helper functions
const hashPassword = async (password: string): Promise<string> => {
return bcrypt.hash(password, 12);
};
const verifyPassword = async (
password: string,
hash: string
): Promise<boolean> => {
return bcrypt.compare(password, hash);
};
const generateTokens = (user: UserWithRelations) => {
const payload = {
userId: user.id,
email: user.email,
role: user.role,
};
const token = jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: "1d" });
const refreshToken = jwt.sign(
{ userId: user.id, type: "refresh" },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: "7d" }
);
return { token, refreshToken };
};
// Query resolvers
const Query: QueryResolvers<GraphQLContext> = {
me: async (_, __, { user, dataSources }) => {
if (!user) return null;
return dataSources.userAPI.findById(user.id);
},
user: async (_, { id, username }, { dataSources }) => {
if (id) {
return dataSources.userAPI.findById(id);
}
if (username) {
return dataSources.userAPI.findByUsername(username);
}
throw new UserInputError("Either id or username must be provided");
},
users: async (_, { first = 20, after, search, role }, { dataSources }) => {
const limit = Math.min(first, 100); // Cap at 100
const cursor = after ? parseCursor(after) : null;
const filters: any = {};
if (search) {
filters.search = search;
}
if (role) {
filters.role = role;
}
const result = await dataSources.userAPI.findMany({
filters,
pagination: {
limit,
cursor,
},
});
const edges = result.data.map(user => ({
node: user,
cursor: createCursor(user.id),
}));
return {
edges,
pageInfo: {
hasNextPage: result.hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: result.totalCount,
};
},
};
// Mutation resolvers
const Mutation: MutationResolvers<GraphQLContext> = {
register: async (_, { input }, { dataSources }) => {
// Validate input
const validatedInput = validateInput(createUserSchema, input);
// Check if user already exists
const existingUser = await dataSources.userAPI.findByEmail(
validatedInput.email
);
if (existingUser) {
throw new UserInputError("User with this email already exists");
}
const existingUsername = await dataSources.userAPI.findByUsername(
validatedInput.username
);
if (existingUsername) {
throw new UserInputError("Username is already taken");
}
// Hash password
const passwordHash = await hashPassword(validatedInput.password);
// Create user
const user = await dataSources.userAPI.create({
...validatedInput,
passwordHash,
});
// Generate tokens
const { token, refreshToken } = generateTokens(user);
return {
token,
refreshToken,
user,
};
},
login: async (_, { input }, { dataSources }) => {
// Validate input
const validatedInput = validateInput(loginSchema, input);
// Find user
const user = await dataSources.userAPI.findByEmail(validatedInput.email);
if (!user) {
throw new AuthenticationError("Invalid credentials");
}
// Verify password
const isPasswordValid = await verifyPassword(
validatedInput.password,
user.passwordHash
);
if (!isPasswordValid) {
throw new AuthenticationError("Invalid credentials");
}
// Check if user is active
if (user.status !== "ACTIVE") {
throw new ForbiddenError("Account is not active");
}
// Update last seen
await dataSources.userAPI.updateLastSeen(user.id);
// Generate tokens
const { token, refreshToken } = generateTokens(user);
return {
token,
refreshToken,
user,
};
},
refreshToken: async (_, { refreshToken }, { dataSources }) => {
try {
const decoded = jwt.verify(
refreshToken,
process.env.JWT_REFRESH_SECRET!
) as any;
if (decoded.type !== "refresh") {
throw new AuthenticationError("Invalid refresh token");
}
const user = await dataSources.userAPI.findById(decoded.userId);
if (!user) {
throw new AuthenticationError("User not found");
}
const tokens = generateTokens(user);
return {
...tokens,
user,
};
} catch (error) {
throw new AuthenticationError("Invalid refresh token");
}
},
logout: async (_, __, { user, dataSources }) => {
if (!user) {
throw new AuthenticationError("Not authenticated");
}
// Set user offline
await dataSources.userAPI.setOffline(user.id);
return true;
},
updateProfile: async (_, { input }, { user, dataSources }) => {
if (!user) {
throw new AuthenticationError("Not authenticated");
}
// Validate input
const validatedInput = validateInput(updateUserSchema, input);
// Check if username is already taken
if (validatedInput.username) {
const existingUser = await dataSources.userAPI.findByUsername(
validatedInput.username
);
if (existingUser && existingUser.id !== user.id) {
throw new UserInputError("Username is already taken");
}
}
// Update user
const updatedUser = await dataSources.userAPI.update(
user.id,
validatedInput
);
return updatedUser;
},
changePassword: async (
_,
{ currentPassword, newPassword },
{ user, dataSources }
) => {
if (!user) {
throw new AuthenticationError("Not authenticated");
}
// Get user with password hash
const userWithPassword = await dataSources.userAPI.findById(user.id, true);
if (!userWithPassword) {
throw new Error("User not found");
}
// Verify current password
const isCurrentPasswordValid = await verifyPassword(
currentPassword,
userWithPassword.passwordHash
);
if (!isCurrentPasswordValid) {
throw new UserInputError("Current password is incorrect");
}
// Validate new password
if (newPassword.length < 8) {
throw new UserInputError(
"New password must be at least 8 characters long"
);
}
// Update password
const newPasswordHash = await hashPassword(newPassword);
await dataSources.userAPI.updatePassword(user.id, newPasswordHash);
return true;
},
deleteAccount: async (_, __, { user, dataSources }) => {
if (!user) {
throw new AuthenticationError("Not authenticated");
}
// Soft delete user
await dataSources.userAPI.softDelete(user.id);
return true;
},
followUser: async (_, { userId }, { user, dataSources }) => {
if (!user) {
throw new AuthenticationError("Not authenticated");
}
if (user.id === userId) {
throw new UserInputError("Cannot follow yourself");
}
// Check if target user exists
const targetUser = await dataSources.userAPI.findById(userId);
if (!targetUser) {
throw new UserInputError("User not found");
}
// Follow user
await dataSources.userAPI.follow(user.id, userId);
// Create notification for followed user
await dataSources.notificationAPI.create({
userId: userId,
type: "FOLLOW",
title: "New Follower",
message: `${user.firstName} ${user.lastName} started following you`,
actionData: {
followerId: user.id,
},
});
return true;
},
unfollowUser: async (_, { userId }, { user, dataSources }) => {
if (!user) {
throw new AuthenticationError("Not authenticated");
}
if (user.id === userId) {
throw new UserInputError("Cannot unfollow yourself");
}
// Unfollow user
await dataSources.userAPI.unfollow(user.id, userId);
return true;
},
};
// Field resolvers
const User: UserFieldResolvers<GraphQLContext> = {
fullName: parent => `${parent.firstName} ${parent.lastName}`,
posts: async (parent, { first = 20, after, orderBy }, { dataSources }) => {
const limit = Math.min(first, 100);
const cursor = after ? parseCursor(after) : null;
const result = await dataSources.postAPI.findMany({
filters: { authorId: parent.id },
pagination: { limit, cursor },
orderBy,
});
const edges = result.data.map(post => ({
node: post,
cursor: createCursor(post.id),
}));
return {
edges,
pageInfo: {
hasNextPage: result.hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: result.totalCount,
};
},
followers: async (parent, { first = 20, after }, { dataSources }) => {
const limit = Math.min(first, 100);
const cursor = after ? parseCursor(after) : null;
const result = await dataSources.userAPI.getFollowers(parent.id, {
limit,
cursor,
});
const edges = result.data.map(follow => ({
node: follow.follower,
cursor: createCursor(follow.id),
}));
return {
edges,
pageInfo: {
hasNextPage: result.hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: result.totalCount,
};
},
following: async (parent, { first = 20, after }, { dataSources }) => {
const limit = Math.min(first, 100);
const cursor = after ? parseCursor(after) : null;
const result = await dataSources.userAPI.getFollowing(parent.id, {
limit,
cursor,
});
const edges = result.data.map(follow => ({
node: follow.following,
cursor: createCursor(follow.id),
}));
return {
edges,
pageInfo: {
hasNextPage: result.hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: result.totalCount,
};
},
addresses: async (parent, _, { dataSources }) => {
return dataSources.userAPI.getAddresses(parent.id);
},
postsCount: async (parent, _, { dataSources }) => {
return dataSources.postAPI.countByAuthor(parent.id);
},
followersCount: async (parent, _, { dataSources }) => {
return dataSources.userAPI.getFollowersCount(parent.id);
},
followingCount: async (parent, _, { dataSources }) => {
return dataSources.userAPI.getFollowingCount(parent.id);
},
};
// Export resolvers
export const userResolvers: Resolvers = {
Query,
Mutation,
User,
};
src/resolvers/userResolvers.ts
Step 5: DataLoader Implementation
Create efficient data loading with DataLoader:
import DataLoader from "dataloader";
import { PrismaClient } from "@prisma/client";
import { UserWithRelations, PostWithRelations } from "@/types/prisma";
export class DataLoaders {
public userById: DataLoader<string, UserWithRelations | null>;
public postById: DataLoader<string, PostWithRelations | null>;
public usersByIds: DataLoader<readonly string[], UserWithRelations[]>;
public postsByAuthorId: DataLoader<string, PostWithRelations[]>;
public followersCountByUserId: DataLoader<string, number>;
public followingCountByUserId: DataLoader<string, number>;
constructor(private prisma: PrismaClient) {
// User by ID loader
this.userById = new DataLoader(
async (userIds: readonly string[]) => {
const users = await this.prisma.user.findMany({
where: {
id: { in: [...userIds] },
deletedAt: null,
},
include: {
addresses: true,
_count: {
select: {
posts: true,
followers: true,
follows: true,
},
},
},
});
const userMap = new Map(users.map(user => [user.id, user]));
return userIds.map(id => userMap.get(id) || null);
},
{
cacheKeyFn: key => `user:${key}`,
maxBatchSize: 100,
}
);
// Post by ID loader
this.postById = new DataLoader(
async (postIds: readonly string[]) => {
const posts = await this.prisma.post.findMany({
where: {
id: { in: [...postIds] },
deletedAt: null,
},
include: {
author: {
select: {
id: true,
username: true,
firstName: true,
lastName: true,
avatar: true,
},
},
tags: true,
_count: {
select: {
comments: { where: { status: "APPROVED" } },
likes: true,
},
},
},
});
const postMap = new Map(posts.map(post => [post.id, post]));
return postIds.map(id => postMap.get(id) || null);
},
{
cacheKeyFn: key => `post:${key}`,
maxBatchSize: 100,
}
);
// Multiple users by IDs loader
this.usersByIds = new DataLoader(
async (userIdArrays: readonly (readonly string[])[]) => {
const allUserIds = [...new Set(userIdArrays.flat())];
const users = await this.prisma.user.findMany({
where: {
id: { in: allUserIds },
deletedAt: null,
},
include: {
addresses: true,
},
});
const userMap = new Map(users.map(user => [user.id, user]));
return userIdArrays.map(
userIds =>
userIds
.map(id => userMap.get(id))
.filter(Boolean) as UserWithRelations[]
);
},
{
cacheKeyFn: key => `users:${key.join(",")}`,
}
);
// Posts by author ID loader
this.postsByAuthorId = new DataLoader(
async (authorIds: readonly string[]) => {
const posts = await this.prisma.post.findMany({
where: {
authorId: { in: [...authorIds] },
deletedAt: null,
},
include: {
author: {
select: {
id: true,
username: true,
firstName: true,
lastName: true,
avatar: true,
},
},
tags: true,
_count: {
select: {
comments: { where: { status: "APPROVED" } },
likes: true,
},
},
},
orderBy: { createdAt: "desc" },
});
const postsByAuthor = new Map<string, PostWithRelations[]>();
posts.forEach(post => {
const authorPosts = postsByAuthor.get(post.authorId) || [];
authorPosts.push(post);
postsByAuthor.set(post.authorId, authorPosts);
});
return authorIds.map(authorId => postsByAuthor.get(authorId) || []);
},
{
cacheKeyFn: key => `posts_by_author:${key}`,
}
);
// Followers count by user ID loader
this.followersCountByUserId = new DataLoader(
async (userIds: readonly string[]) => {
const counts = await this.prisma.follow.groupBy({
by: ["followingId"],
where: {
followingId: { in: [...userIds] },
},
_count: {
followerId: true,
},
});
const countMap = new Map(
counts.map(count => [count.followingId, count._count.followerId])
);
return userIds.map(id => countMap.get(id) || 0);
},
{
cacheKeyFn: key => `followers_count:${key}`,
}
);
// Following count by user ID loader
this.followingCountByUserId = new DataLoader(
async (userIds: readonly string[]) => {
const counts = await this.prisma.follow.groupBy({
by: ["followerId"],
where: {
followerId: { in: [...userIds] },
},
_count: {
followingId: true,
},
});
const countMap = new Map(
counts.map(count => [count.followerId, count._count.followingId])
);
return userIds.map(id => countMap.get(id) || 0);
},
{
cacheKeyFn: key => `following_count:${key}`,
}
);
}
// Clear all caches
clearAll(): void {
this.userById.clearAll();
this.postById.clearAll();
this.usersByIds.clearAll();
this.postsByAuthorId.clearAll();
this.followersCountByUserId.clearAll();
this.followingCountByUserId.clearAll();
}
// Clear cache for specific user
clearUser(userId: string): void {
this.userById.clear(userId);
this.postsByAuthorId.clear(userId);
this.followersCountByUserId.clear(userId);
this.followingCountByUserId.clear(userId);
}
// Clear cache for specific post
clearPost(postId: string): void {
this.postById.clear(postId);
}
}
export const createDataLoaders = (prisma: PrismaClient) =>
new DataLoaders(prisma);
src/dataloaders/index.ts
Best Practices Summary
- Schema Design: Use schema-first approach with comprehensive type definitions
- Code Generation: Leverage GraphQL Code Generator for type safety
- Resolvers: Implement efficient resolvers with proper error handling
- DataLoader: Use DataLoader to prevent N+1 query problems
- Authentication: Implement proper JWT authentication and authorization
- Validation: Use Zod or similar libraries for input validation
- Performance: Optimize queries with field-level caching and batching
- Security: Implement rate limiting, depth limiting, and query complexity analysis
- Testing: Write comprehensive tests for resolvers and business logic
- Documentation: Maintain clear schema documentation and examples
Development Commands
# Generate types
npm run codegen
# Start development server
npm run dev
# Build for production
npm run build
# Run tests
npm test
# Lint code
npm run lint
Your GraphQL TypeScript API is now ready for production with comprehensive schema design, type safety, efficient data loading, and modern GraphQL features!