TypeORM from Beginner to Expert: A Learning Path
TypeORM is one of the most popular Object-Relational Mappers (ORMs) in the TypeScript and Node.js ecosystem. It allows developers to interact with relational databases using TypeScript classes instead of raw SQL queries, while still giving you the option to drop down to SQL when needed. Whether you are building a small REST API or a large-scale microservice architecture, mastering TypeORM will dramatically improve your productivity and code maintainability.
This tutorial walks you through a complete learning path — from understanding what TypeORM is, to advanced patterns used by senior engineers in production systems. By the end, you will have a solid mental model of TypeORM and a toolkit of best practices you can apply immediately.
What Is TypeORM?
TypeORM is an ORM that runs in Node.js and is written entirely in TypeScript. It supports the Active Record and Data Mapper patterns, which means you can choose the architectural style that best fits your project. It works with MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, SAP Hana, and even MongoDB (though MongoDB is not relational, TypeORM offers limited support).
At its core, TypeORM maps TypeScript classes to database tables. Each property of a class becomes a column, and decorators like @Entity, @Column, and @PrimaryGeneratedColumn describe the schema. TypeORM then generates the SQL needed to create, read, update, and delete records.
Why TypeORM Matters
- Type safety: You get compile-time checks on your entities and queries, reducing runtime errors.
- Productivity: Schema synchronization, migrations, and query builders save hours of boilerplate.
- Pattern flexibility: Active Record for small apps, Data Mapper for complex domains.
- Framework integration: First-class support for NestJS, Express, Fastify, and others.
- SQL escape hatch: When the ORM gets in the way, you can always write raw SQL.
Step 1: Installation and Project Setup
Start by initializing a new Node.js project and installing TypeORM along with a database driver. In this tutorial, we will use PostgreSQL, but the concepts apply to any supported database.
mkdir typeorm-tutorial && cd typeorm-tutorial
npm init -y
npm install typeorm pg reflect-metadata
npm install -D typescript ts-node @types/node
TypeORM relies on reflect-metadata to read type information at runtime, so it must be imported once at the entry point of your application. Create a tsconfig.json file with the following configuration:
{
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "ES2021",
"module": "CommonJS",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
Step 2: Connecting to the Database
TypeORM uses a DataSource object to manage the connection to your database. This object is the central hub for entities, migrations, and query execution. Create a file named src/data-source.ts:
import "reflect-metadata";
import { DataSource } from "typeorm";
export const AppDataSource = new DataSource({
type: "postgres",
host: "localhost",
port: 5432,
username: "postgres",
password: "postgres",
database: "tutorial",
synchronize: true, // dev only — never use in production
logging: true,
entities: ["src/entities/**/*.ts"],
migrations: ["src/migrations/**/*.ts"],
});
The synchronize option automatically creates and alters tables to match your entities. This is extremely useful during development but dangerous in production because it can cause data loss. Always disable it in production and rely on migrations instead.
To initialize the connection, create src/index.ts:
import { AppDataSource } from "./data-source";
async function main() {
await AppDataSource.initialize();
console.log("Database connected!");
}
main().catch((err) => {
console.error("Connection error:", err);
process.exit(1);
});
Step 3: Defining Entities
An entity is a TypeScript class decorated with @Entity that maps to a database table. Let's create a User entity in src/entities/User.ts:
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ unique: true })
email: string;
@Column()
firstName: string;
@Column()
lastName: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
Here, @PrimaryGeneratedColumn("uuid") tells TypeORM to auto-generate a UUID for each new row. @CreateDateColumn and @UpdateDateColumn are special columns that TypeORM manages automatically.
Step 4: CRUD Operations with the Repository Pattern
TypeORM's Data Mapper pattern uses repositories to interact with entities. You obtain a repository from the DataSource and call methods like find, save, update, and delete.
import { AppDataSource } from "./data-source";
import { User } from "./entities/User";
async function run() {
await AppDataSource.initialize();
const userRepo = AppDataSource.getRepository(User);
// Create
const user = userRepo.create({
email: "jane@example.com",
firstName: "Jane",
lastName: "Doe",
});
await userRepo.save(user);
// Read
const found = await userRepo.findOneBy({ email: "jane@example.com" });
console.log(found);
// Update
if (found) {
found.firstName = "Janet";
await userRepo.save(found);
}
// Delete
await userRepo.delete({ email: "jane@example.com" });
}
run();
The create method instantiates an entity in memory but does not persist it. The save method performs the actual INSERT or UPDATE. Understanding this distinction is crucial for avoiding subtle bugs.
Step 5: Relationships
Real-world schemas involve relationships between tables. TypeORM supports one-to-one, one-to-many, many-to-one, and many-to-many relationships. Let's add a Post entity that has a many-to-one relationship with User:
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from "typeorm";
import { User } from "./User";
@Entity()
export class Post {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column()
title: string;
@Column("text")
content: string;
@ManyToOne(() => User, (user) => user.posts, { eager: false })
@JoinColumn({ name: "author_id" })
author: User;
@Column()
authorId: string;
}
Update the User entity to include the inverse side:
import { OneToMany } from "typeorm";
import { Post } from "./Post";
// inside the User class
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
The eager option, when set to true, automatically loads the related entity whenever you query the parent. Use it sparingly because it can lead to performance problems with large datasets.
Working with Relations in Queries
To load related entities on demand, use the relations option or the QueryBuilder:
const userWithPosts = await userRepo.findOne({
where: { id: someId },
relations: { posts: true },
});
Step 6: The QueryBuilder
The QueryBuilder is TypeORM's most powerful feature. It lets you build complex SQL queries programmatically with full type safety. Here is an example that fetches active users who have published at least one post:
const users = await AppDataSource.getRepository(User)
.createQueryBuilder("user")
.leftJoinAndSelect("user.posts", "post")
.where("user.isActive = :active", { active: true })
.andWhere("post.id IS NOT NULL")
.orderBy("user.createdAt", "DESC")
.skip(0)
.take(10)
.getMany();
Always use parameter binding (the :param syntax) instead of string interpolation to prevent SQL injection attacks. This is one of the most important security practices when using TypeORM.
Step 7: Migrations
In production, you should never use synchronize. Instead, use migrations to version-control your schema changes. First, generate a migration from your entities:
npx typeorm migration:generate src/migrations/InitialSchema -d src/data-source.ts
This creates a TypeScript file with up and down methods. Here is what a typical migration looks like:
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitialSchema1700000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "user" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"email" character varying NOT NULL UNIQUE,
"firstName" character varying NOT NULL,
"lastName" character varying NOT NULL,
"isActive" boolean NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_user_id" PRIMARY KEY ("id")
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "user"`);
}
}
Run migrations with:
npx typeorm migration:run -d src/data-source.ts
And revert the last migration with:
npx typeorm migration:revert -d src/data-source.ts
Step 8: Transactions
Transactions ensure that a group of operations either all succeed or all fail. TypeORM provides multiple ways to handle transactions. The cleanest approach uses the transaction method on the DataSource:
await AppDataSource.transaction(async (manager) => {
const user = manager.create(User, {
email: "bob@example.com",
firstName: "Bob",
lastName: "Smith",
});
await manager.save(user);
const post = manager.create(Post, {
title: "Hello World",
content: "My first post",
authorId: user.id,
});
await manager.save(post);
});
If any operation inside the callback throws an error, the entire transaction is rolled back automatically. This is essential when you need to maintain data consistency across multiple tables.
Step 9: Subscribers and Listeners
TypeORM lets you hook into entity lifecycle events using subscribers and listeners. For example, you can automatically hash a password before inserting a user:
import {
EntitySubscriberInterface,
EventSubscriber,
InsertEvent,
} from "typeorm";
import bcrypt from "bcrypt";
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
listenTo() {
return User;
}
async beforeInsert(event: InsertEvent<User>) {
if (event.entity.password) {
const salt = await bcrypt.genSalt(10);
event.entity.password = await bcrypt.hash(event.entity.password, salt);
}
}
}
Register subscribers in your DataSource configuration by adding subscribers: ["src/subscribers/**/*.ts"].
Step 10: Advanced Patterns
Custom Repositories
For complex domains, you can extend the base repository with custom methods. This keeps your business logic organized and reusable:
import { Repository, DataSource } from "typeorm";
import { User } from "../entities/User";
export class UserRepository extends Repository<User> {
findActiveUsersByDomain(domain: string): Promise<User[]> {
return this.createQueryBuilder("user")
.where("user.isActive = :active", { active: true })
.andWhere("user.email LIKE :domain", { domain: `%@${domain}` })
.getMany();
}
}
// Usage
const customRepo = AppDataSource.getCustomRepository(UserRepository);
// Note: getCustomRepository is deprecated in newer versions.
// Use extend instead:
const extendedRepo = AppDataSource.getRepository(User).extend(new UserRepository());
Soft Deletes
TypeORM supports soft deletes out of the box. Add the @DeleteDateColumn decorator to your entity:
@DeleteDateColumn()
deletedAt: Date | null;
Then use softDelete instead of delete:
await userRepo.softDelete({ id: someId });
Soft-deleted records are excluded from normal queries but can be retrieved with withDeleted:
const allUsers = await userRepo.find({ withDeleted: true });
Best Practices
- Never use
synchronizein production. Always use migrations to manage schema changes safely. - Use parameter binding in QueryBuilder. Never interpolate user input directly into queries.
- Prefer the Data Mapper pattern for large applications. It keeps entities simple and separates persistence logic from domain logic.
- Be cautious with
eagerrelations. They can cause N+1 problems and load unnecessary data. Load relations explicitly when needed. - Use indexes on frequently queried columns. Add
@Index()to columns used inwhereclauses. - Keep entities lean. Avoid putting business logic inside entity classes. Use services for complex operations.
- Always handle connection errors gracefully. Use retry logic or circuit breakers in production environments.
- Log queries in development, not in production. Excessive logging can degrade performance and leak sensitive data.
- Use DTOs for input validation. Combine TypeORM with libraries like
class-validatorto validate data before it reaches the database. - Test with a real database. Mocking the ORM can hide bugs. Use a containerized database in your test suite for reliable results.
Common Pitfalls
- N+1 query problem: Loading a list of entities and then accessing their relations one by one triggers a separate query per entity. Use
relationsorleftJoinAndSelectto fetch everything in one query. - Forgetting to save: Calling
createdoes not persist data. Always follow it withsave. - Misusing
savewith partial updates: Thesavemethod updates all columns. For partial updates, useupdateor the QueryBuilder. - Ignoring transaction boundaries: Operations that must be atomic should always be wrapped in a transaction.
Conclusion
TypeORM is a powerful and flexible ORM that scales from quick prototypes to enterprise applications. By following this learning path — from setting up a connection and defining entities, to mastering the QueryBuilder, migrations, transactions, and advanced patterns — you now have the knowledge to use TypeORM effectively in any project. The key to becoming an expert is not memorizing every API method, but understanding the underlying SQL that TypeORM generates and knowing when to let the ORM do the work and when to take control yourself. Keep practicing with real-world scenarios, write migrations for every schema change, and always measure query performance in production. With these habits, TypeORM will become a reliable foundation for your data layer for years to come.