TypeGraphQL builds GraphQL APIs with TypeScript decorators — @ObjectType() class User { @Field() id: string; @Field() email: string } defines a GraphQL type. @Resolver(User) class UserResolver { @Query(() => [User]) async users(@Ctx() ctx: Context) { return ctx.db.users.findMany() } } defines a resolver. @InputType() class CreateUserInput { @Field() @IsEmail() email: string } with class-validator for input validation. @Mutation(() => User) async createUser(@Arg("input") input: CreateUserInput, @Ctx() ctx: Context) adds a mutation. @FieldResolver(() => [Post]) async posts(@Root() user: User, @Ctx() ctx: Context) adds a field resolver for relations. buildSchema({ resolvers: [UserResolver, PostResolver] }) creates the executable schema. @Authorized() or @Authorized("ADMIN") uses the authChecker function for auth guards. Middleware: UseMiddleware(LogAccess) runs functions before resolvers. Claude Code generates TypeGraphQL resolvers, input types, field resolvers with DataLoader, and schema setup.
CLAUDE.md for TypeGraphQL
## TypeGraphQL Stack
- Version: type-graphql >= 2.0, reflect-metadata, class-validator >= 0.14
- tsconfig: "experimentalDecorators": true, "emitDecoratorMetadata": true
- Import reflect-metadata first: import "reflect-metadata" at app entry
- ObjectType: @ObjectType() class Post { @Field() id: string; @Field() title: string }
- Resolver: @Resolver(Post) class PostResolver { @Query(() => [Post]) posts(@Ctx() ctx: Context) { return ctx.db.posts } }
- Input: @InputType() class CreatePost { @Field() @MinLength(5) title: string; @Field() content: string }
- Auth: @Authorized() on query/mutation + authChecker: ({ context }) => context.userId !== null in buildSchema
- Build: const schema = await buildSchema({ resolvers: [PostResolver], authChecker, validate: true })
Type Definitions
// lib/graphql/types.ts — TypeGraphQL ObjectType definitions
import "reflect-metadata"
import {
ObjectType, Field, ID, Int, Float, InputType,
registerEnumType,
} from "type-graphql"
import {
IsEmail, MinLength, MaxLength, IsOptional, IsUrl, ArrayMaxSize,
} from "class-validator"
// Enum
export enum UserRole {
USER = "user",
ADMIN = "admin",
}
registerEnumType(UserRole, {
name: "UserRole",
description: "User role in the system",
})
// ── Output types ───────────────────────────────────────────────────────────
@ObjectType({ description: "A user account" })
export class User {
@Field(() => ID)
id: string
@Field()
email: string
@Field()
name: string
@Field(() => UserRole)
role: UserRole
@Field({ nullable: true })
avatarUrl?: string
@Field()
createdAt: Date
// posts is resolved via @FieldResolver — not stored on User directly
}
@ObjectType({ description: "A blog post" })
export class Post {
@Field(() => ID)
id: string
@Field()
title: string
@Field()
slug: string
@Field()
content: string
@Field({ nullable: true })
excerpt?: string
@Field()
published: boolean
@Field(() => Int)
readingTime: number
@Field({ nullable: true })
publishedAt?: Date
@Field()
createdAt: Date
@Field()
updatedAt: Date
// author is resolved via @FieldResolver
}
@ObjectType()
export class PaginatedPosts {
@Field(() => [Post])
posts: Post[]
@Field(() => Int)
total: number
@Field(() => Int)
page: number
@Field(() => Int)
pageSize: number
@Field(() => Int)
totalPages: number
}
// ── Input types ────────────────────────────────────────────────────────────
@InputType({ description: "Input for creating a new post" })
export class CreatePostInput {
@Field()
@MinLength(5, { message: "Title must be at least 5 characters" })
@MaxLength(200)
title: string
@Field()
@MinLength(20)
content: string
@Field({ nullable: true })
@IsOptional()
@MaxLength(500)
excerpt?: string
@Field(() => Boolean, { defaultValue: false })
published: boolean = false
@Field(() => [String], { defaultValue: [] })
@ArrayMaxSize(10)
tags: string[] = []
}
@InputType()
export class UpdatePostInput {
@Field({ nullable: true })
@IsOptional()
@MinLength(5)
@MaxLength(200)
title?: string
@Field({ nullable: true })
@IsOptional()
@MinLength(20)
content?: string
@Field({ nullable: true })
@IsOptional()
@MaxLength(500)
excerpt?: string
@Field({ nullable: true })
@IsOptional()
published?: boolean
}
@InputType()
export class PostsFilter {
@Field(() => Int, { defaultValue: 1 })
page: number = 1
@Field(() => Int, { defaultValue: 10 })
pageSize: number = 10
@Field({ nullable: true })
search?: string
@Field({ nullable: true })
tag?: string
@Field({ nullable: true })
published?: boolean
}
Resolvers
// lib/graphql/resolvers/PostResolver.ts — TypeGraphQL resolver
import "reflect-metadata"
import {
Resolver, Query, Mutation, Arg, Ctx, FieldResolver,
Root, Authorized, Int, ID, UseMiddleware,
} from "type-graphql"
import { Post, PaginatedPosts, CreatePostInput, UpdatePostInput, PostsFilter, User } from "../types"
import type { AppContext } from "../context"
import DataLoader from "dataloader"
import { db } from "@/lib/db"
import { posts, users } from "@/lib/db/schema"
import { eq, ilike, and, count, desc, inArray } from "drizzle-orm"
@Resolver(() => Post)
export class PostResolver {
// ── Queries ──────────────────────────────────────────────────────────────
@Query(() => PaginatedPosts, { description: "List posts with pagination" })
async posts(@Arg("filter", { defaultValue: {} }) filter: PostsFilter): Promise<PaginatedPosts> {
const { page, pageSize, search, published } = filter
const offset = (page - 1) * pageSize
const where = []
if (search) where.push(ilike(posts.title, `%${search}%`))
if (published !== undefined && published !== null) {
where.push(eq(posts.published, published))
}
const [result, totalResult] = await Promise.all([
db.query.posts.findMany({
where: where.length > 0 ? and(...where) : undefined,
limit: pageSize,
offset,
orderBy: desc(posts.createdAt),
}),
db.select({ count: count() }).from(posts),
])
const total = totalResult[0]?.count ?? 0
return {
posts: result as Post[],
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}
@Query(() => Post, { nullable: true })
async post(@Arg("id", () => ID) id: string): Promise<Post | null> {
const post = await db.query.posts.findFirst({ where: eq(posts.id, id) })
return (post as Post) ?? null
}
@Query(() => Post, { nullable: true })
async postBySlug(@Arg("slug") slug: string): Promise<Post | null> {
const post = await db.query.posts.findFirst({ where: eq(posts.slug, slug) })
return (post as Post) ?? null
}
// ── Field resolvers ──────────────────────────────────────────────────────
@FieldResolver(() => User, { nullable: true })
async author(@Root() post: Post, @Ctx() ctx: AppContext): Promise<User | null> {
// Use DataLoader to batch-load authors
if (!ctx.userLoader) return null
return (ctx.userLoader.load((post as any).authorId) as Promise<User | null>)
}
@FieldResolver(() => Int)
readingTime(@Root() post: Post): number {
return Math.ceil(post.content.split(/\s+/).length / 200)
}
// ── Mutations ────────────────────────────────────────────────────────────
@Authorized()
@Mutation(() => Post)
async createPost(
@Arg("input") input: CreatePostInput,
@Ctx() ctx: AppContext,
): Promise<Post> {
const slug = input.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
const [post] = await db.insert(posts).values({
...input,
slug,
authorId: ctx.userId!,
}).returning()
return post as Post
}
@Authorized()
@Mutation(() => Post)
async updatePost(
@Arg("id", () => ID) id: string,
@Arg("input") input: UpdatePostInput,
@Ctx() ctx: AppContext,
): Promise<Post> {
const existing = await db.query.posts.findFirst({ where: eq(posts.id, id) })
if (!existing) throw new Error("Post not found")
if ((existing as any).authorId !== ctx.userId) throw new Error("Forbidden")
const [updated] = await db.update(posts).set(input).where(eq(posts.id, id)).returning()
return updated as Post
}
@Authorized()
@Mutation(() => Boolean)
async deletePost(
@Arg("id", () => ID) id: string,
@Ctx() ctx: AppContext,
): Promise<boolean> {
const existing = await db.query.posts.findFirst({ where: eq(posts.id, id) })
if (!existing) throw new Error("Post not found")
if ((existing as any).authorId !== ctx.userId) throw new Error("Forbidden")
await db.delete(posts).where(eq(posts.id, id))
return true
}
}
Schema Builder
// lib/graphql/schema.ts — build the TypeGraphQL schema
import "reflect-metadata"
import { buildSchema, type AuthChecker } from "type-graphql"
import { PostResolver } from "./resolvers/PostResolver"
import { UserResolver } from "./resolvers/UserResolver"
import type { AppContext } from "./context"
const authChecker: AuthChecker<AppContext> = ({ context }, roles) => {
if (!context.userId) return false
if (roles.length === 0) return true
if (roles.includes("ADMIN") && !context.isAdmin) return false
return true
}
// In production, build once and reuse
let _schema: Awaited<ReturnType<typeof buildSchema>> | null = null
export async function getSchema() {
if (_schema) return _schema
_schema = await buildSchema({
resolvers: [PostResolver, UserResolver],
authChecker,
validate: { forbidUnknownValues: true },
dateScalarMode: "isoDate",
})
return _schema
}
For the Pothos alternative when a functional builder API (non-decorator, no reflect-metadata) with better TypeScript inference, relay plugin for cursor pagination, and more active maintenance is preferred — Pothos is generally recommended for new projects as it doesn’t require experimental decorators, while TypeGraphQL is better for teams already familiar with NestJS or class-based OOP patterns, see the Pothos guide. For the Nexus alternative when nexus.objectType and nexus.makeSchema functional APIs are preferred with a plugin for Prisma integration — Nexus is less actively maintained as of 2025 compared to Pothos, but may be appropriate for teams with existing Nexus codebases, see the Nexus guide. The Claude Skills 360 bundle includes TypeGraphQL skill sets covering resolvers, field resolvers, and schema building. Start with the free tier to try decorator-based GraphQL generation.