End-to-end type safety from schema to query
Prisma generates a fully typed database client from a single schema file. Every query, every relation and every field is known at compile time, which makes entire classes of runtime errors at the database boundary disappear.
Table of Contents
- 1. schema.prisma as the single source of truth
- 2. Generating and wiring up the client
- 3. Type-safe queries with full autocompletion
- 4. Relations and nested write operations
- 5. Error handling with typed Prisma errors
- 6. Migrations as a versioned transition between schema states
- 7. The boundary between database types and input validation
- 8. Keeping transactions and the N+1 problem under control
- 9. Prisma in edge and serverless environments
- 10. Summary
- 11. FAQ
1. schema.prisma as the single source of truth
Prisma inverts the classic ORM model. Instead of defining models as TypeScript classes and maintaining types by hand, a declarative file called schema.prisma describes the data model, the database connection and generators in its own compact syntax. From this file, the prisma generate command produces a fully typed client that matches the defined models, fields and relations exactly.
The key advantage over handwritten interfaces lies in synchronization: if a field name or data type changes in the schema, every place in the code that references the old state immediately fails with a compiler error. There is no drift between database structure and TypeScript types, because both originate from the same source.
// 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[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
}
2. Generating and wiring up the client
After every schema change, prisma generate rebuilds the matching client, by default inside node_modules/@prisma/client. This step typically runs automatically as a postinstall hook, so CI pipelines and fresh checkouts always have an up to date client without anyone needing to remember it.
In application code, a single PrismaClient instance is created and reused. This matters especially in serverless and edge environments, since every new instance opens its own connection to the database, which under many concurrent function invocations quickly exhausts the connection pool.
// src/db/client.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
3. Type-safe queries with full autocompletion
Every method on the generated client, such as findUnique, findMany or create, is overloaded with the exact field names and types from the schema. A typo in a filter's field name is rejected immediately by the compiler, and the return type of a query automatically adapts to whichever fields were actually requested via select or include.
Prisma calls this behavior internally generated types with conditional fields: if only id and email are requested, the return type contains exclusively these two fields, with no placeholder nulls for the rest. That prevents code from accidentally accessing a field that was never loaded in the first place.
import { prisma } from '../db/client';
async function getUserWithPosts(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
posts: {
where: { published: true },
select: { id: true, title: true },
},
},
});
// user.posts is Array<{ id: string; title: string }>, not the full Post type
return user;
}
4. Relations and nested write operations
Relations are declared in the schema through the @relation attribute and appear in the generated client as typed relationships that can be read and written in nested form. When creating a user together with their first post, the compiler checks that the nested create structure matches the fields of the Post model exactly.
For more complex cases such as many-to-many relations with extra fields on the join table, Prisma generates its own model for the intermediate table, handled with the same type safety as any other model. That avoids the typical stringly-typed pitfalls of classic query builders for nested inserts.
const userWithPost = await prisma.user.create({
data: {
email: 'dev@mironsoft.de',
name: 'Developer',
posts: {
create: [{ title: 'First post', published: false }],
},
},
include: { posts: true },
});
5. Error handling with typed Prisma errors
On database errors, Prisma throws specific error classes such as PrismaClientKnownRequestError, carrying a typed code value, for example P2002 for a violated unique constraint. An instanceof check reliably distinguishes this error type from generic JavaScript errors, without falling back to fragile string comparisons of the error message.
For production applications, a central error handling layer that maps Prisma error codes onto domain specific error classes pays off. This keeps business logic decoupled from the concrete ORM implementation, so a later switch of the underlying database driver does not require changes scattered throughout the codebase.
import { Prisma } from '@prisma/client';
async function createUser(email: string) {
try {
return await prisma.user.create({ data: { email } });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new Error(`Email ${email} is already taken`);
}
throw error;
}
}
6. Migrations as a versioned transition between schema states
Prisma Migrate generates a SQL migration file for every schema change, describing the transition from the previous state to the new one. These files are versioned inside prisma/migrations and shared across the team, so every environment goes through the exact same history instead of relying on implicit schema synchronization.
The command prisma migrate dev generates and applies migrations immediately during development, while prisma migrate deploy in production environments only executes migrations that were already generated and reviewed. This separation prevents unreviewed schema changes from landing in production unintentionally.
# Generate and apply a new migration from a schema change
npx prisma migrate dev --name add-post-published-flag
# In production, run only existing migrations
npx prisma migrate deploy
7. The boundary between database types and input validation
Prisma types describe what can be stored in the database, not what can safely be accepted from an incoming HTTP request. A common trap is passing an unvalidated request body directly as a data object into a Prisma method, since TypeScript raises no warning as long as the structure happens to fit.
The robust approach combines Prisma with a validation library such as Zod at the outer boundary of the application. Only after successful validation of a raw, unknown input value does a typed object emerge that is then passed to Prisma. That keeps responsibility clearly separated: Zod secures the boundary to the outside world, Prisma secures the boundary to the database.
import { z } from 'zod';
const CreateUserInput = z.object({
email: z.string().email(),
name: z.string().min(1).optional(),
});
async function handleCreateUser(rawBody: unknown) {
const input = CreateUserInput.parse(rawBody);
return prisma.user.create({ data: input });
}
8. Keeping transactions and the N+1 problem under control
For related write operations, Prisma offers two transaction APIs: the sequential array form $transaction([...]) for independent operations, and the interactive form with a callback for cases where later steps depend on the results of earlier ones. Both variants are fully typed, and the return type of the interactive transaction matches exactly what the callback returns.
The classic N+1 problem, where a query per list item triggers additional database calls, can be avoided using include and select, since Prisma collapses nested relations internally into efficient joins or batch queries instead of naively loading them one row at a time.
const [user, post] = await prisma.$transaction(async (tx) => {
const user = await tx.user.update({
where: { id: 'u1' },
data: { name: 'Updated' },
});
const post = await tx.post.create({
data: { title: 'New post', authorId: user.id },
});
return [user, post];
});
9. Prisma in edge and serverless environments
The classic PrismaClient relies on a native Rust engine, which runs without issue in traditional Node.js environments but is unavailable in edge runtimes such as Cloudflare Workers. For these cases, Prisma offers an Accelerate mode as well as a driver adapter mechanism that delegates query execution over HTTP to a remote database proxy, without changing the typed API surface.
From the application code's perspective, the switch stays transparent: the same typed method calls work regardless of whether a direct TCP connection pool or an HTTP based proxy runs underneath. That makes Prisma workable even for multi runtime projects, as long as the data source configuration is carefully matched to the target environment.
| Aspect | Prisma | TypeORM | Drizzle |
|---|---|---|---|
| Type generation | Generated client from schema.prisma | Decorator based entities | Schema written directly in TypeScript |
| Migrations | Prisma Migrate with SQL history | Built in migrations CLI | drizzle-kit generates SQL |
| Query style | Fluent, object based | Repository and query builder pattern | SQL like, functional API |
| Bundle size | Requires Rust engine or Accelerate | Pure JavaScript | Very lean, no codegen needed |
| Learning curve | Low thanks to its own DSL | Medium, modeled after ORMs like Doctrine | Medium, SQL knowledge helpful |
Mironsoft
TypeScript migration, type safety, and team onboarding
A JavaScript codebase without type safety, but no time for a full migration?
We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.
Migration Roadmap
Plan and execute a gradual JS-to-TS migration without big-bang risk.
Strict Mode Rollout
Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.
Team Onboarding
Bring developers up to speed on TypeScript best practices with workshops and reviews.
10. Summary
TypeScript with Prisma
Source of truth
schema.prisma defines models, relations and data source in one place
Type safety
Generated client matches return types exactly to select/include
Error handling
PrismaClientKnownRequestError with typed codes like P2002
Validation boundary
Zod validates input before it reaches Prisma