Skip to main content

TypeScript ORMs

You're staring at a runtime error: Cannot read property 'email' of undefined. Again. Your query returned data, but the shape doesn't match what your code expects. You've been manually writing SQL queries and mapping results to TypeScript types, and somewhere between the database and your application, things got out of sync. This is exactly where TypeScript ORMs shine. They bridge your code and database, giving you type safety from your queries all the way to your data models, catching these mismatches at compile time instead of in production.

Choosing the Right TypeScript ORM for Your Project

Picking the right ORM matters because each one makes different trade-offs between type safety, performance, and developer experience. The TypeScript ORM landscape in 2025 offers several mature options: Prisma, Drizzle, TypeORM, and Sequelize each excel in different scenarios.

Prisma uses a schema-first approach where you define your data model in Prisma's own schema language, then it generates a fully type-safe client for you. This means your TypeScript types stay perfectly in sync with your database schema. It's excellent for greenfield projects where you want maximum type safety with minimal boilerplate.

Drizzle ORM is the new lightweight contender. It's TypeScript-first with zero dependencies, weighing in at just 7.4kb. If you know SQL, you'll feel right at home because Drizzle's API mirrors SQL syntax closely. It's perfect for serverless environments where bundle size matters and you want SQL-like control without giving up type safety.

TypeORM is the most flexible option, supporting both Active Record and Data Mapper patterns. It uses decorators on TypeScript classes to define entities, which feels natural if you're coming from frameworks like Hibernate or Entity Framework. TypeORM gives you more control over raw SQL when you need it, making it ideal for complex legacy applications or when you need to optimize specific queries.

Sequelize is the stable, battle-tested choice. It's been around longer than the others and has extensive documentation and community support. While its TypeScript support isn't as strong as Prisma or Drizzle, it's a solid pick for teams maintaining existing codebases or those who prefer a promise-based API.

Quick Comparison: Which ORM Should You Use?

ORMBest ForType SafetyLearning CurveBundle Size
PrismaGreenfield projects, maximum type safety, teams wanting best-in-class DXExcellent (auto-generated)Gentle~13kb
DrizzleServerless apps, developers who think in SQL, performance-critical appsExcellent (SQL-like)Minimal if you know SQL~7.4kb
TypeORMComplex apps, legacy databases, teams needing Active Record or Data MapperGood (decorator-based)Steeper~300kb
SequelizeExisting projects, teams familiar with Sequelize, broad database supportModerateModerate~200kb

Here's what a basic TypeORM setup looks like:

// Example of a simple ORM setup with TypeORM
import { Entity, PrimaryGeneratedColumn, Column, DataSource } from 'typeorm';

@Entity()
class User {
@PrimaryGeneratedColumn()
id: number;

@Column()
name: string;

@Column()
email: string;
}

// Modern TypeORM uses DataSource instead of createConnection
const AppDataSource = new DataSource({
type: "postgres",
host: "localhost",
port: 5432,
username: "postgres",
password: "password",
database: "myapp",
entities: [User],
synchronize: true, // Don't use in production
});

await AppDataSource.initialize();
console.log("Connected to the database!");

Using a TypeScript ORM with Convex's Backend

Convex is a backend platform that provides tools for building scalable applications. When using a TypeScript ORM with Convex, you need to understand how they work together. While Convex provides its own database API that's already type-safe and JavaScript-native, you might need an ORM when integrating with external databases. For complex data filtering needs, check out Convex's complex filters guide.

// Convex doesn't require an ORM for its own database
// This example shows how you might use an ORM for external data
import { query } from "./_generated/server";
import { User } from "./entities/User";
import { AppDataSource } from "./data-source";

export const getExternalUsers = query(async () => {
// For external database using TypeORM
const userRepository = AppDataSource.getRepository(User);
const users = await userRepository.find();

// Store in Convex for quick access
// This is theoretical - convert external data to Convex format
return users;
});

Setting Up TypeScript ORMs from Scratch

The setup process varies significantly between ORMs. Let's walk through setting up both TypeORM and Prisma so you can see the different approaches.

TypeORM Setup

TypeORM provides a CLI to scaffold a new project:

npx typeorm init --database postgres

After initialization, install dependencies and configure your connection:

// src/data-source.ts
import { DataSource } from "typeorm";
import { User } from "./entity/User";

export const AppDataSource = new DataSource({
type: "postgres",
host: "localhost",
port: 5432,
username: "test",
password: "test",
database: "test",
synchronize: true, // Auto-creates tables - disable in production
logging: false,
entities: [User],
migrations: [],
subscribers: [],
});

Prisma Setup

Prisma takes a different approach with its schema file:

npm install prisma --save-dev
npx prisma init

This creates a prisma/schema.prisma file where you define your data model:

// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

generator client {
provider = "prisma-client-js"
}

model User {
id Int @id @default(autoincrement())
name String
email String @unique
}

After defining your schema, generate the type-safe client:

npx prisma generate
npx prisma db push // Creates tables in your database

Drizzle Setup

Drizzle's setup is the most lightweight:

npm install drizzle-orm postgres
npm install -D drizzle-kit

Define your schema in TypeScript directly:

// src/db/schema.ts
import { pgTable, serial, text } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
});
// src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });

Performing CRUD Operations with TypeScript ORMs

CRUD operations are the foundation of database interactions. Let's see how different ORMs handle them.

TypeORM CRUD Operations

TypeORM uses repositories to manage entities. The modern API relies on DataSource rather than the deprecated getRepository():

import { AppDataSource } from './data-source';
import { User } from './entity/User';

// Initialize the data source first
await AppDataSource.initialize();

const userRepository = AppDataSource.getRepository(User);

// Create
const newUser = userRepository.create({
name: 'Sarah Chen',
email: 'sarah.chen@example.com'
});
await userRepository.save(newUser);

// Read
const allUsers = await userRepository.find();
const specificUser = await userRepository.findOneBy({ id: 1 });

// Update
if (specificUser) {
specificUser.name = 'Sarah Chen-Smith';
await userRepository.save(specificUser);
}

// Delete
await userRepository.delete({ id: 1 });

Prisma CRUD Operations

Prisma's auto-generated client provides a clean, type-safe API:

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// Create
const newUser = await prisma.user.create({
data: {
name: 'Sarah Chen',
email: 'sarah.chen@example.com'
}
});

// Read - notice the autocomplete on all fields
const allUsers = await prisma.user.findMany();
const specificUser = await prisma.user.findUnique({
where: { id: 1 }
});

// Update
const updatedUser = await prisma.user.update({
where: { id: 1 },
data: { name: 'Sarah Chen-Smith' }
});

// Delete
await prisma.user.delete({
where: { id: 1 }
});

Drizzle CRUD Operations

Drizzle feels closest to writing SQL:

import { db } from './db';
import { users } from './db/schema';
import { eq } from 'drizzle-orm';

// Create
const [newUser] = await db.insert(users).values({
name: 'Sarah Chen',
email: 'sarah.chen@example.com'
}).returning();

// Read
const allUsers = await db.select().from(users);
const specificUser = await db.select()
.from(users)
.where(eq(users.id, 1));

// Update
await db.update(users)
.set({ name: 'Sarah Chen-Smith' })
.where(eq(users.id, 1));

// Delete
await db.delete(users)
.where(eq(users.id, 1));

Configuring Database Relationships in TypeScript ORMs

Defining relationships between entities is crucial when modeling complex data. Different ORMs handle relationships differently. When working with TypeScript extends, you can inherit properties from base entity classes. TypeScript decorators power TypeORM's relationship definitions.

TypeORM Relationships

TypeORM uses decorators to define relationships:

import { Entity, Column, PrimaryGeneratedColumn, OneToOne, OneToMany, ManyToMany, JoinColumn, JoinTable } from 'typeorm';

@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;

@Column()
name: string;

// One-to-one: Each user has one profile
@OneToOne(() => Profile, (profile) => profile.user)
@JoinColumn()
profile: Profile;

// One-to-many: Each user has many posts
@OneToMany(() => Post, (post) => post.author)
posts: Post[];

// Many-to-many: Users can belong to many teams
@ManyToMany(() => Team, (team) => team.members)
@JoinTable()
teams: Team[];
}

@Entity()
export class Profile {
@PrimaryGeneratedColumn()
id: number;

@Column()
bio: string;

@OneToOne(() => User, (user) => user.profile)
user: User;
}

Prisma Relationships

Prisma defines relationships in the schema file:

model User {
id Int @id @default(autoincrement())
name String
profile Profile? // One-to-one (optional)
posts Post[] // One-to-many
teams Team[] // Many-to-many
}

model Profile {
id Int @id @default(autoincrement())
bio String
userId Int @unique
user User @relation(fields: [userId], references: [id])
}

model Post {
id Int @id @default(autoincrement())
title String
authorId Int
author User @relation(fields: [authorId], references: [id])
}

model Team {
id Int @id @default(autoincrement())
name String
members User[]
}

Drizzle Relationships

Drizzle defines relationships using TypeScript and helper functions:

import { pgTable, serial, text, integer } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";

export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
});

export const profiles = pgTable("profiles", {
id: serial("id").primaryKey(),
bio: text("bio").notNull(),
userId: integer("user_id").notNull().references(() => users.id),
});

export const usersRelations = relations(users, ({ one, many }) => ({
profile: one(profiles, {
fields: [users.id],
references: [profiles.userId],
}),
posts: many(posts),
}));

Solving the N+1 Query Problem in TypeScript ORMs

The N+1 query problem is one of the most common performance killers when using ORMs. It happens when you fetch a list of records and then loop through them, triggering a separate query for each record's related data. If you fetch 100 users and then access each user's posts, you'll execute 1 query for users + 100 queries for posts = 101 total queries.

Here's what the N+1 problem looks like in practice:

// ❌ BAD: This triggers N+1 queries
const users = await userRepository.find(); // 1 query

for (const user of users) {
// Each iteration triggers a new query - if you have 100 users, that's 100 more queries
console.log(user.posts);
}

Solution 1: Eager Loading with Relations (TypeORM)

The simplest fix is to load related data upfront:

// ✅ GOOD: Single query with a JOIN
const users = await userRepository.find({
relations: ['posts']
});

// Now accessing posts doesn't trigger new queries
for (const user of users) {
console.log(user.posts); // Already loaded
}

Solution 2: QueryBuilder with Joins (TypeORM)

For more control, use the QueryBuilder:

const users = await AppDataSource
.getRepository(User)
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.leftJoinAndSelect('user.profile', 'profile')
.where('user.isActive = :active', { active: true })
.getMany();

Solution 3: Prisma's Include Pattern

Prisma handles eager loading with the include option:

// ❌ BAD: N+1 problem with Prisma
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({
where: { authorId: user.id }
});
}

// ✅ GOOD: Load related data upfront
const users = await prisma.user.findMany({
include: {
posts: true,
profile: true
}
});

Solution 4: DataLoader Pattern for GraphQL APIs

If you're building a GraphQL API or have complex data fetching patterns, DataLoader batches and caches requests:

import DataLoader from "dataloader";

// Batch load posts by user IDs
const postLoader = new DataLoader(async (userIds: readonly number[]) => {
const posts = await postRepository
.createQueryBuilder("post")
.where("post.authorId IN (:...userIds)", { userIds })
.getMany();

// Group posts by user ID to maintain order
const postsByUserId = userIds.map(id =>
posts.filter(post => post.authorId === id)
);

return postsByUserId;
});

// Usage: DataLoader batches multiple calls into one query
const user1Posts = await postLoader.load(1);
const user2Posts = await postLoader.load(2);
// These execute as a single query: WHERE authorId IN (1, 2)

Detecting N+1 Queries

Enable query logging to spot N+1 problems during development:

// TypeORM logging
const AppDataSource = new DataSource({
// ... other config
logging: true, // or ['query', 'error']
});

// Prisma logging
const prisma = new PrismaClient({
log: ['query', 'info', 'warn', 'error'],
});

Watch your logs for patterns like "SELECT * FROM posts WHERE user_id = 1", "SELECT * FROM posts WHERE user_id = 2", etc. That's your N+1 smoking gun.

When to Use an ORM vs Raw SQL

ORMs aren't always the right choice. Here's a practical framework for deciding:

Use an ORM When:

You're building standard CRUD apps. If most of your database interactions are basic creates, reads, updates, and deletes, ORMs save you tons of time. You get type safety and don't have to write repetitive SQL.

Type safety matters more than raw performance. ORMs like Prisma and Drizzle catch database schema mismatches at compile time. This prevents entire classes of runtime errors.

Your team is small or junior-heavy. ORMs provide guardrails. They prevent SQL injection by default and make it harder to write inefficient queries (though not impossible).

You need to support multiple databases. Switching from PostgreSQL to MySQL with an ORM often requires minimal code changes. With raw SQL, you're rewriting vendor-specific queries.

Use Raw SQL When:

Performance is absolutely critical. Complex reports, analytics queries, or operations on millions of rows often need hand-tuned SQL. ORMs add overhead and can't optimize as well as a human who understands the data.

You need database-specific features. PostgreSQL's full-text search, MySQL's JSON functions, or any vendor-specific syntax won't map well to an ORM's abstraction layer.

The query is complex with multiple joins and subqueries. At a certain point, fighting with an ORM's query builder to generate the SQL you want becomes harder than just writing SQL. For complex data retrieval patterns, consider integrating with Convex's end-to-end TypeScript solution to maintain type safety from database to frontend.

You're working with legacy schemas. Badly normalized databases with odd naming conventions and relationships often fight against ORM patterns.

The Hybrid Approach

Most real applications use both. Use your ORM for 80% of queries (CRUD operations, simple joins) and drop down to raw SQL for the performance-critical 20%:

// TypeORM allows raw SQL when needed
const results = await AppDataSource.query(`
SELECT u.name, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
WHERE u.created_at > $1
GROUP BY u.id
ORDER BY post_count DESC
LIMIT 10
`, [startDate]);

// Prisma's raw query support
const results = await prisma.$queryRaw`
SELECT u.name, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
WHERE u.created_at > ${startDate}
GROUP BY u.id
ORDER BY post_count DESC
LIMIT 10
`;

Improving Performance with TypeScript ORMs

Beyond avoiding N+1 queries, there are several other ways to optimize ORM performance.

Use Query Builders for Complex Queries

Query builders give you more control than simple repository methods:

import { AppDataSource } from './data-source';
import { User } from './entity/User';

const userRepository = AppDataSource.getRepository(User);

const activeUsersWithPosts = await userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.where('user.isActive = :active', { active: true })
.andWhere('post.publishedAt IS NOT NULL')
.orderBy('user.createdAt', 'DESC')
.limit(50)
.getMany();

Select Only What You Need

Don't fetch entire entities when you only need a few fields:

// Prisma - select specific fields
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true
// Don't load the 'profile' or 'posts' relations
}
});

// TypeORM - partial selection
const users = await userRepository
.createQueryBuilder('user')
.select(['user.id', 'user.name', 'user.email'])
.getMany();

Add Database Indexes

When working with TypeScript interfaces, ensure your migrations reflect changes to your interface definitions. ORMs let you define indexes to speed up queries:

// TypeORM indexes
@Entity()
@Index(['email']) // Single column index
@Index(['lastName', 'firstName']) // Composite index
export class User {
@PrimaryGeneratedColumn()
id: number;

@Column()
@Index() // You can also add indexes directly on columns
email: string;

@Column()
firstName: string;

@Column()
lastName: string;
}
// Prisma indexes
model User {
id Int @id @default(autoincrement())
email String @unique // Unique constraint creates an index
firstName String
lastName String

@@index([email])
@@index([lastName, firstName])
}

Use Connection Pooling

Configure connection pools to reuse database connections:

const AppDataSource = new DataSource({
type: "postgres",
host: "localhost",
port: 5432,
username: "test",
password: "test",
database: "test",
extra: {
max: 20, // Maximum pool size
min: 5, // Minimum pool size
idleTimeoutMillis: 30000
}
});

Managing Migrations and Schema Changes

Handling migrations and schema changes is vital to keep your database structure updated. Most ORMs provide migration tools and schema comparators to manage these changes.

TypeORM Migrations

TypeORM provides a CLI to create and run migrations:

npx typeorm migration:create src/migration/AddEmailToUser

This creates a migration file:

// src/migration/1234567890-AddEmailToUser.ts
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddEmailToUser1234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "user"
ADD "email" varchar(255) NOT NULL
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "user"
DROP COLUMN "email"
`);
}
}

Run migrations with:

npx typeorm migration:run -d src/data-source.ts

Prisma Migrations

Prisma's migration workflow is more automated:

# Create a migration from schema changes
npx prisma migrate dev --name add-email-to-user

# Apply migrations in production
npx prisma migrate deploy

Prisma generates the SQL for you based on your schema changes:

-- Generated migration file
ALTER TABLE "User" ADD COLUMN "email" TEXT NOT NULL;

Migration Best Practices

Never use synchronize: true in production. This auto-syncs your entities with the database, which can cause data loss.

Always test migrations against production data. Clone your production database to staging and run migrations there first.

Write reversible migrations. Always implement the down method so you can rollback if something goes wrong.

Keep migrations small and focused. One logical change per migration makes them easier to debug and rollback.

Common Problems You'll Encounter

While using TypeScript ORMs, you'll run into some recurring issues. Here's how to handle them.

Type Mismatches Between Code and Database

Your TypeScript types say a field is required, but the database allows nulls. Or vice versa.

// Entity says email is required
@Column()
email: string;

// But database schema allows NULL
// Solution: Make TypeScript type match reality
@Column({ nullable: true })
email: string | null;

With Prisma, run npx prisma db pull to sync your schema with the actual database structure.

Circular Dependencies in Relationships

When two entities reference each other, TypeScript can't resolve the types:

// ❌ Circular dependency issue
@Entity()
export class User {
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
}

@Entity()
export class Post {
@ManyToOne(() => User, (user) => user.posts)
author: User;
}

The solution is to use forward references (which we're already doing with arrow functions), but make sure to import types properly and avoid importing the entire module.

Migration Conflicts in Team Environments

Two developers create migrations simultaneously, leading to conflicting schema changes. Use a migration naming strategy that includes timestamps (most ORMs do this by default) and communicate schema changes early.

Performance Issues with Eager Loading

Loading too much data with eager loading can be as bad as N+1 queries:

// ❌ Loading everything upfront might be overkill
const users = await userRepository.find({
relations: ['posts', 'posts.comments', 'posts.comments.author', 'profile']
});

Be selective about what you load. Use lazy loading or DataLoader for data you might not always need.

Final Thoughts on TypeScript ORMs

TypeScript ORMs eliminate entire categories of runtime errors by catching database schema mismatches at compile time. Whether you choose Prisma for its best-in-class developer experience, Drizzle for its lightweight SQL-like approach, TypeORM for its flexibility, or Sequelize for its maturity, you're getting type safety and productivity gains that raw SQL can't match.

The key is knowing when to use each tool. Lean on your ORM for standard CRUD operations and let it handle the tedious work of mapping database rows to TypeScript objects. But don't be afraid to drop down to raw SQL when performance matters or when the query complexity fights against your ORM's abstractions.

Start with the hybrid approach: use your ORM for 80% of queries, optimize the remaining 20% with raw SQL, and always keep an eye on your query logs during development to catch N+1 problems early.