Prisma ORM: Building a Type-Safe Database Layer
I walk through Prisma's schema file, generated client, CRUD operations, and relations, with a focus on how it integrates with TypeScript to give you compile-time safety on every database query.

Most TypeScript developers write careful, type-checked application code, then the moment they touch the database, all of that discipline disappears. A query returns any. A column gets accessed with a string key. A typo in a field name silently returns undefined instead of a compile error.
Prisma changes that relationship. Your database schema becomes the source of truth for your TypeScript types — the same way an interface shapes an object, schema.prisma shapes every query result you’ll ever write.
I’m going to walk through the building blocks: the schema file, the generated client, CRUD operations, relations, and migrations. The thread running through all of it is how each piece contributes to the type-safety story.
On this page
- The schema file: one place to describe your data
- Generating the client: where TypeScript types come from
- CRUD operations and what the generated types look like
- Relations: querying connected models without losing types
- Migrations: keeping your schema and database in sync
- Where Prisma’s type safety has limits
- The schema as your contract
The schema file: one place to describe your data
Prisma’s entry point is a file called schema.prisma. It sits in a prisma/ directory at your project root. Everything flows from it — the database connection, the generated client, and the shape of your data.
A minimal schema for a blog looks like this:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
createdAt DateTime @default(now())
}
Three blocks do three different jobs. The datasource block points Prisma at your database — in this case PostgreSQL, with the connection string pulled from an environment variable. The generator block tells Prisma what to generate when you run prisma generate (the standard prisma-client-js generates the TypeScript client). The model blocks define your tables.
Each field in a model has a name, a type, and optional attributes. The @id attribute marks the primary key. @default(autoincrement()) sets a database-level default. The ? after String makes a field nullable. The @unique attribute adds a unique constraint on the email column.
If you’ve read my introduction to PostgreSQL for developers, that vocabulary will feel familiar — Prisma’s field types map directly to database column types.
Generating the client: where TypeScript types come from
Once your schema is defined, you run:
npx prisma generate
This reads schema.prisma and writes a typed TypeScript client into node_modules/@prisma/client. The generation step is what connects your schema definition to your TypeScript code.
What gets generated isn’t a generic database driver — it’s a client shaped exactly like your schema. For the models above, you get a PrismaClient with prisma.user and prisma.post namespaces, and every method on those namespaces knows the exact shape of its inputs and outputs.
The generated types look like this for the User model:
export type User = {
id: number;
email: string;
name: string | null;
createdAt: Date;
};
Prisma derives this type directly from your schema. The field name name is String? in Prisma’s schema language, so it becomes string | null in TypeScript. createdAt is DateTime, which maps to Date. These are not annotations you write — they’re generated every time you run prisma generate.
Before you continue: What do you expect the TypeScript type to be for a
Boolean @default(false)field in your schema?
It becomes boolean — never boolean | null, because the field has no ? modifier. Prisma’s type derivation is strict about optional versus required.
Check this before moving on
- Your
schema.prismahas adatasourceblock with a validproviderandurl - Your
schema.prismahas agenerator clientblock - You’ve run
npx prisma generateafter the last schema change -
@prisma/clientis in yourdependencies, notdevDependencies
CRUD operations and what the generated types look like
You instantiate the client once at the module level:
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
Every operation on prisma.user is now typed against the User model. Here’s what the four most common operations look like:
Reading records:
// findMany returns User[]
const users = await prisma.user.findMany();
// findUnique returns User | null
const user = await prisma.user.findUnique({
where: { email: "alice@example.com" },
});
Creating a record:
// The data argument is typed — missing required fields or wrong types are compile errors
const newUser = await prisma.user.create({
data: {
email: "bob@example.com",
name: "Bob",
},
});
// newUser is typed as User
Updating a record:
const updated = await prisma.post.update({
where: { id: 42 },
data: { published: true },
});
// updated is typed as Post
Deleting a record:
await prisma.post.delete({
where: { id: 42 },
});
The key detail is that data in create and update is not Record<string, any>. It’s a generated input type that knows exactly which fields are required, which are optional, and which types each field accepts. If you try to pass published: "yes" instead of published: true, TypeScript catches it before the code runs.
The same applies to where. The where argument for findUnique only accepts fields marked @unique or @id. If you pass where: { name: "Bob" } and name has no unique constraint, TypeScript will reject it.
Relations: querying connected models without losing types
This is where Prisma’s type system gets interesting. When you query a User with their posts included, the return type changes based on what you asked for.
const userWithPosts = await prisma.user.findUnique({
where: { id: 1 },
include: { posts: true },
});
// userWithPosts is User & { posts: Post[] } | null
Without include, posts isn’t on the returned object at all — and TypeScript knows this. If you try to access userWithPosts.posts without the include, the compiler stops you.
You can go deeper with nested includes:
const user = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: "desc" },
},
},
});
The select alternative lets you fetch only specific fields:
const emailsOnly = await prisma.user.findMany({
select: { email: true },
});
// emailsOnly is { email: string }[]
Notice the return type isn’t User[] — it’s { email: string }[]. Prisma’s generated types narrow the result to exactly what you selected. This matters for runtime performance (you’re not loading columns you don’t need) and for TypeScript correctness (you can’t access fields you didn’t fetch).
For creating nested records, Prisma gives you a relational API that avoids raw foreign keys:
const newPost = await prisma.post.create({
data: {
title: "Understanding database indexes",
author: {
connect: { id: 1 },
},
},
});
The connect syntax links the new post to an existing user. If you prefer, create instead of connect creates the related record in the same query. Both are typed — connect accepts a where object valid for the related model, and create accepts the same input type as prisma.user.create.
If you want to understand the database concepts behind these relations — foreign keys, join tables — my PostgreSQL fundamentals post covers the underlying mechanics.

The schema on the left shapes the generated client on the right — each model field becomes a TypeScript type with the exact nullability and structure you defined.
Migrations: keeping your schema and database in sync
Defining a model in schema.prisma doesn’t change your actual database. For that, you need Prisma Migrate.
The development workflow uses a single command:
npx prisma migrate dev --name add_post_status
This does three things: compares your current schema against the migration history, generates a SQL migration file in prisma/migrations/, and applies that file to your development database. The --name flag labels the migration — you’ll see it in the migration directory as something like 20250524143200_add_post_status.
If you add a new field to your Post model:
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
status String @default("draft") // new field
author User @relation(fields: [authorId], references: [id])
authorId Int
createdAt DateTime @default(now())
}
Running prisma migrate dev generates and applies an ALTER TABLE statement. The migration file is SQL that you can read and review before it runs. This is different from ORMs that apply migrations automatically without showing you the SQL.
For production deployments, you use a separate command that applies pending migrations without generating new ones:
npx prisma migrate deploy
The migration history lives in prisma/migrations/ and should be committed to version control. Each migration file is the record of exactly what changed and when.
Where Prisma’s type safety has limits
Prisma’s type safety is strong, but it isn’t total. A few situations break out of the typed model.
Raw queries. When you use prisma.$queryRaw, the return type is unknown[] by default. You can add a generic type parameter, but Prisma doesn’t verify that the type you provide matches what the query actually returns:
// The generic tells TypeScript the shape, but Prisma can't check it against the SQL
const result = await prisma.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count FROM "Post"
`;
JSON fields. If a model has a Json field, Prisma types it as Prisma.JsonValue — a union of all JSON-compatible types. You can store and retrieve anything that’s valid JSON, but reading a specific property requires a manual type assertion.
The select / include problem at the function boundary. When you pass a partial query result to a function that expects a full model type, TypeScript will refuse to accept it:
function processUser(user: User) {
// ...
}
const partialUser = await prisma.user.findUnique({
where: { id: 1 },
select: { email: true },
});
processUser(partialUser); // TypeScript error: missing fields
This is correct behaviour — partialUser really is missing the id, name, and createdAt fields. The fix is either to broaden the function signature using Prisma’s generated utility types, or to ensure the query fetches the full model.
The Prisma namespace exports utility types for exactly this case. Prisma.UserGetPayload<T> lets you describe the exact shape a function expects, including which relations are included.
If you want to go deeper on utility types and type narrowing in TypeScript, the pattern here is the same as generic constraints — my post on TypeScript generics covers the underlying concept.
The schema as your contract
The interesting shift Prisma makes is moving the database description out of SQL comments and migration scripts and into a file that TypeScript can read.
Before Prisma (or with a raw query builder), the columns in your users table exist in the database, maybe in a migration file, maybe in separate TypeScript interface declarations — and those three things can drift apart without any warning. You rename a column in the database, forget to update the TypeScript interface, and the mismatch only surfaces at runtime.
With Prisma, schema.prisma is the single source of truth. The database gets its structure from Prisma Migrate’s SQL output. Your TypeScript code gets its types from prisma generate. Both derive from the same file, so they can’t drift without you explicitly changing the schema.
The practical effect: when you add a required field to a model, every create call in your codebase that doesn’t include that field becomes a compile error. You find those gaps at build time instead of in a 3am production alert.
Start with schema.prisma, keep it in version control alongside your migrations, and run prisma generate as part of your build step. If you’re coming from raw SQL, the REST API with Node.js and Express post is a good reference for the Express layer that typically sits on top of this kind of database client.