I’ve been in your shoes, staring at a screen full of runtime errors that should have been caught before the code ever ran. For years, I built APIs where the database types, business logic, and API contracts lived in separate worlds. A change in one place meant manual updates elsewhere, and bugs slipped through constantly. Then I discovered a stack that changed everything: NestJS, Prisma, and a code-first GraphQL approach. This combination lets you define your data once and have type safety everywhere. No more mismatched types. No more guessing. I want to show you how to build APIs where errors are caught as you type, not when your users complain.
Have you ever spent hours debugging a simple type mismatch? Imagine catching those issues before your code even runs.
Let’s start with the foundation. You’ll need Node.js installed. Open your terminal and create a new NestJS project. I prefer starting fresh to avoid clutter.
nest new type-safe-api
cd type-safe-api
Now, install the core packages. This might look like a lot, but each one has a specific job.
npm install @nestjs/graphql graphql apollo-server-express @nestjs/apollo
npm install prisma @prisma/client
npm install class-validator class-transformer
Why these packages? NestJS gives us structure. GraphQL provides a smart API layer. Prisma talks to our database. The class tools help validate data. Together, they form a safety net.
First, configure GraphQL in NestJS. We use the code-first method. This means we write our TypeScript classes, and NestJS creates the GraphQL schema for us. It’s a single source of truth.
// app.module.ts
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
playground: true,
}),
],
})
export class AppModule {}
This setup automatically generates a schema.gql file. Your GraphQL types will always match your TypeScript code. What happens when your data needs a custom date format? We can define that.
Now, let’s connect a database. Prisma makes this type-safe. Create a prisma folder and a schema.prisma file.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
posts Post[]
}
After defining your models, run npx prisma generate. This creates a Prisma Client tailored to your schema. Every database query is now type-checked.
But how do we bridge our database models to GraphQL? We create TypeScript classes that serve as both GraphQL types and validation blueprints.
// users/dto/create-user.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsString, MinLength } from 'class-validator';
@InputType()
export class CreateUserInput {
@Field()
@IsEmail()
email: string;
@Field()
@IsString()
@MinLength(3)
name: string;
@Field()
@IsString()
@MinLength(8)
password: string;
}
See the @Field() decorators? Those tell GraphQL about this type. The class-validator decorators like @IsEmail() ensure the data is correct before it hits your business logic. This validation runs automatically.
With the types set, we need resolvers to handle queries and mutations. Resolvers are where your business logic lives. In NestJS, they’re simple classes.
// users/users.resolver.ts
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { UsersService } from './users.service';
import { User } from './models/user.model';
import { CreateUserInput } from './dto/create-user.input';
@Resolver(() => User)
export class UsersResolver {
constructor(private usersService: UsersService) {}
@Query(() => [User], { name: 'users' })
async getUsers() {
return this.usersService.findAll();
}
@Mutation(() => User)
async createUser(@Args('createUserInput') input: CreateUserInput) {
return this.usersService.create(input);
}
}
The @Query() and @Mutation() decorators define our GraphQL operations. Notice the () => User syntax? That’s TypeScript ensuring the return type matches our User model. Everything is connected.
What about protecting certain data? Authorization is crucial. We can use guards in NestJS to control access.
// common/guards/gql-auth.guard.ts
import { ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
}
Then, in your resolver, use @UseGuards(GqlAuthGuard) to protect a query or mutation. This ensures only authorized users can access certain parts of your API.
Real-time updates are a game-changer for user experience. GraphQL subscriptions make this easy. How do we notify clients when new data arrives?
// posts/posts.resolver.ts
import { Subscription } from '@nestjs/graphql';
import { PubSub } from 'graphql-subscriptions';
const pubSub = new PubSub();
@Resolver(() => Post)
export class PostsResolver {
@Subscription(() => Post)
postCreated() {
return pubSub.asyncIterator('postCreated');
}
@Mutation(() => Post)
async createPost(@Args('input') input: CreatePostInput) {
const newPost = await this.postsService.create(input);
await pubSub.publish('postCreated', { postCreated: newPost });
return newPost;
}
}
When a new post is created, all subscribed clients get an update. It’s efficient and keeps your UI in sync.
But errors will happen. How we handle them defines our API’s reliability. NestJS GraphQL lets us format errors consistently.
// in your GraphQL config
formatError: (error) => {
return {
message: error.message,
code: error.extensions?.code || 'INTERNAL_ERROR',
};
}
We can also throw specific errors in our services.
throw new GraphQLError('User not found', {
extensions: { code: 'NOT_FOUND' },
});
This gives clients clear error codes to handle.
Performance is key. A common issue in GraphQL is the “N+1 problem” where one query triggers many database calls. With Prisma, we can optimize this using eager loading.
// In your service
async findAll() {
return this.prisma.user.findMany({
include: {
posts: true, // Fetches posts in the same query
},
});
}
Prisma batches queries where possible, but being explicit about relations helps avoid unnecessary calls.
Testing might seem tedious, but it saves headaches. How do you ensure your GraphQL API works as expected? We write integration tests.
// test/users.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../src/app.module';
describe('UsersResolver (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('should create a user', () => {
const mutation = `
mutation {
createUser(createUserInput: {
email: "[email protected]",
name: "Test User",
password: "password123"
}) {
id
email
}
}
`;
return request(app.getHttpServer())
.post('/graphql')
.send({ query: mutation })
.expect(200)
.expect((res) => {
expect(res.body.data.createUser.email).toBe('[email protected]');
});
});
});
This test sends a real GraphQL query and checks the response. It catches issues early.
Throughout my projects, I’ve learned that the biggest time-saver is consistency. With this stack, your types are synchronized from the database to the API client. You spend less time debugging and more time building features.
I encourage you to start small. Define one model, create a resolver, and see how the types flow. Once you experience that confidence, you’ll never go back.
If you found this guide helpful, please like, share, or comment below with your experiences. Your feedback helps others learn, and I’d love to hear what you build with these tools.