Recently, I encountered multiple production issues caused by runtime type mismatches in a GraphQL API. This experience drove me to explore how we can achieve end-to-end type safety from database to client. By combining TypeScript, TypeGraphQL, and Prisma, we can create robust APIs that catch errors during development rather than in production. Let me show you how this powerful trio eliminates entire categories of bugs while boosting productivity.
First, we establish our foundation. Create a new project and install essential dependencies:
npm init -y
npm install @apollo/server graphql typegraphql prisma @prisma/client
npm install -D typescript ts-node @types/node
This stack gives us a type-safe pipeline: Prisma generates TypeScript types from our database schema, while TypeGraphQL uses those same types to generate GraphQL schemas. Notice how we’re not writing any GraphQL SDL manually? The system infers everything from our TypeScript code.
Now, let’s model our database. The Prisma schema defines our data structures and relationships:
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
title String
author User @relation(fields: [authorId], references: [id])
authorId String
}
When we run npx prisma generate
, Prisma creates a fully-typed client. This means every database query we write will have autocompletion and type checking. Ever accidentally tried to fetch a non-existent field? That mistake gets caught immediately in your editor.
Next, we mirror these types in our GraphQL layer using TypeGraphQL decorators:
// src/models/Post.ts
import { ObjectType, Field, ID } from "typegraphql";
@ObjectType()
export class Post {
@Field(() => ID)
id: string;
@Field()
title: string;
@Field(() => User)
author: User;
}
The @Field
decorators automatically generate GraphQL schema definitions. But how do we connect these to our database operations? That’s where resolvers come in:
// src/resolvers/PostResolver.ts
import { Resolver, Query, Mutation, Arg } from "typegraphql";
import { Post } from "../models/Post";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
@Resolver(Post)
export class PostResolver {
@Query(() => [Post])
async posts(): Promise<Post[]> {
return prisma.post.findMany({ include: { author: true } });
}
@Mutation(() => Post)
async createPost(@Arg("title") title: string): Promise<Post> {
return prisma.post.create({
data: { title, author: { connect: { id: "user1" } } },
include: { author: true }
});
}
}
Notice the include: { author: true }
? That ensures we resolve relationships correctly. But what happens when we need field-level authorization? Let’s implement protection on sensitive operations:
import { UseMiddleware, Authorized } from "typegraphql";
@Mutation(() => Post)
@UseMiddleware(AuthMiddleware)
@Authorized("ADMIN")
async deletePost(@Arg("id") id: string): Promise<Post> {
return prisma.post.delete({ where: { id } });
}
The @Authorized
decorator integrates seamlessly with our middleware. For authentication, I prefer a simple JWT approach:
// src/middlewares/AuthMiddleware.ts
import { MiddlewareFn } from "typegraphql";
import { verify } from "jsonwebtoken";
export const AuthMiddleware: MiddlewareFn = async ({ context }, next) => {
const token = context.req.headers.authorization?.split(" ")[1];
if (!token) throw new Error("Not authenticated");
try {
const payload = verify(token, "SECRET_KEY");
context.user = payload as User;
} catch {
throw new Error("Invalid token");
}
return next();
};
Now, let’s tackle performance. The N+1 problem can cripple GraphQL APIs. Consider this: fetching 10 posts might trigger 11 database queries (1 for posts + 10 for authors). We solve this with DataLoader:
// src/loaders/userLoader.ts
import DataLoader from "dataloader";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const userLoader = new DataLoader(async (userIds: string[]) => {
const users = await prisma.user.findMany({
where: { id: { in: userIds } }
});
const userMap = users.reduce((map, user) => {
map[user.id] = user;
return map;
}, {});
return userIds.map(id => userMap[id] || null);
});
Attach this loader to your context and watch it batch requests. For example, resolving post authors now uses:
@FieldResolver(() => User)
async author(@Root() post: Post, @Ctx() ctx: Context): Promise<User> {
return ctx.loaders.userLoader.load(post.authorId);
}
What about real-time updates? Subscriptions are surprisingly straightforward:
@Subscription(() => Post, { topics: "POST_CREATED" })
postCreated(): Post {
return {} as Post; // Placeholder - payload comes from publisher
}
@Mutation(() => Post)
async createPost(
@PubSub() pubSub: PubSubEngine,
@Arg("title") title: string
): Promise<Post> {
const post = await prisma.post.create({ data: { title } });
await pubSub.publish("POST_CREATED", post);
return post;
}
For deployment, I’ve had great success with containerized environments. Use this Dockerfile as a starting point:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
CMD ["node", "dist/server.js"]
Monitoring is crucial in production. I recommend exporting metrics to Prometheus and setting up alerts for abnormal resolver timings. One hidden gem: TypeGraphQL’s built-in validation integrates beautifully with class-validator:
import { Length, IsEmail } from "class-validator";
@InputType()
class CreateUserInput {
@Field()
@Length(3, 30)
name: string;
@Field()
@IsEmail()
email: string;
}
This catches invalid inputs before they reach your business logic. Throughout this journey, I’ve eliminated countless runtime errors by leveraging the type system. The immediate feedback in my IDE when I rename a field or change a type has saved hours of debugging.
Ready to implement these patterns? Start with a small module and gradually add features. You’ll quickly see how these tools work together to create a safety net that catches mistakes early. If you found this guide helpful, share it with your team and leave a comment about your experience. What type-safety challenges have you faced in your projects?