TypeScript ORM

TypeORM

TypeORM maps TypeScript classes to database tables with decorators. It supports both the Data Mapper and Active Record patterns, and ships repositories, a QueryBuilder and migrations in one package.

intermediate15 min readUpdated Sep 16, 2026
user.entity.ts
ts
// user.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  OneToMany,
  CreateDateColumn,
} from "typeorm";
import { Post } from "./post.entity";

@Entity("users")
export class User {
  @PrimaryGeneratedColumn("uuid")
  id!: string;

  @Column({ unique: true })
  email!: string;

  @Column({ name: "display_name" })
  displayName!: string;

  @CreateDateColumn({ name: "created_at" })
  createdAt!: Date;

  @OneToMany(() => Post, (post) => post.author)
  posts!: Post[];
}
Released
2016
Language
TypeScript / JavaScript
Patterns
Data Mapper and Active Record
Databases
Postgres, MySQL, SQLite, MSSQL, Oracle
Style
Decorator-based entities
Current line
0.3.x

Why it matters

What TypeORM brings to a TypeScript backend

Decorator-driven entities

Classes and property decorators describe tables and columns, so the schema lives in typed code the compiler can check.

First-class relations

@ManyToOne, @OneToMany and @ManyToMany map foreign keys and join tables, with eager or explicit loading on your terms.

Migrations from entities

Generate migration files by diffing entities against the database, then run them in order across every environment.

The big picture

Three ideas that shape every TypeORM app

Entities describe tables, a DataSource owns the connection, and repositories or the QueryBuilder read and write rows.

Entities

Model

A decorated class maps to a table, and each decorated property maps to a column or a relation.

DataSource

Connect

One object holds the connection options and creates entity managers, repositories and transactions.

Repositories

Query

Typed repositories offer find, findOne and save, while the QueryBuilder covers anything they cannot express.

At a glance

The TypeORM toolkit

Column decorators

@Column, @PrimaryGeneratedColumn, @CreateDateColumn and friends define the table shape.

Relations

@ManyToOne, @OneToMany, @OneToOne and @ManyToMany wire tables together.

Repository API

find, findOne, save, remove, count and findAndCount with typed where clauses.

QueryBuilder

Chain select, join, where and orderBy, or drop to raw SQL when you need it.

Migrations

migration:generate writes SQL from entity diffs; migration:run applies it.

Transactions

dataSource.transaction and QueryRunner wrap multi-step writes atomically.

Data model

A user and their posts

The User entity owns many Post rows, and each Post points back to one author through a foreign key.

The users and posts tablesTypeORM entities
  • iduuidGenerated primary key, exposed as a string in TypeScript
  • emailtextUnique column, enforced by a database constraint
  • displayNametextMapped to the display_name column
  • createdAttimestamptzSet automatically by @CreateDateColumn
  • postsPost[]One-to-many relation, loaded only when you ask for it

The User entity owns many Post rows, and each Post points back to one author through a foreign key.

A short history

From decorators to the DataSource

  1. 2016

    TypeORM is released

    A decorator-based ORM for TypeScript arrives, aiming to feel natural in a typed codebase.

    16
  2. 2018

    The NestJS years

    @nestjs/typeorm makes TypeORM the default data layer for a generation of Nest applications.

    18
  3. 2020

    Migrations and subscribers mature

    Schema diffing, entity subscribers and lifecycle listeners round out the framework.

    20
  4. 2022

    Version 0.3 and the DataSource

    createConnection is replaced by an explicit DataSource that owns the connection and its options.

    22
  5. 2024

    A mature, widely deployed ORM

    TypeORM stays common in NestJS backends even as Drizzle and Prisma attract new projects.

    24

The complete guide

TypeORM: Everything you need to know

What is TypeORM?

TypeORM is an object-relational mapper for TypeScript and JavaScript. It lets you describe your database as a set of classes decorated with metadata, then query it through repositories or a fluent builder. It has been around since 2016 and is the ORM most associated with NestJS.

Its distinguishing feature is that it supports two well-known patterns at once. In Active Record, an entity knows how to load and save itself. In Data Mapper, entities are plain objects and a separate repository owns persistence. Most teams use Data Mapper for application code and occasionally Active Record for small, self-contained models.

If you already know SQL, TypeORM is best understood as a mapping layer: entities become tables, decorators become column definitions, and every query eventually becomes SQL you could have written by hand.

Entities: classes that become tables

An entity is a class marked with @Entity(). Each instance is a row, and each decorated property is a column.

import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";

@Entity("users")
export class User {
  @PrimaryGeneratedColumn("uuid")
  id!: string;

  @Column({ unique: true })
  email!: string;

  @Column({ name: "display_name" })
  displayName!: string;
}

The argument to @Entity is the table name. If you omit it, TypeORM derives one from the class name, but being explicit avoids surprises when names are refactored. The name option on @Column decouples the database column from the TypeScript property, which is how displayName maps to display_name.

The ! after each property is the definite assignment assertion. TypeScript cannot see that the ORM fills these fields at runtime, so ! tells the compiler to trust the database.

Column decorators you will actually use

TypeORM infers the column type from the property’s TypeScript type, but inference is limited. A string could be text, varchar or char, so for anything precise, pass the type explicitly.

@Entity("posts")
export class Post {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ type: "text" })
  body!: string;

  @Column({ type: "integer", default: 0 })
  views!: number;

  @Column({ type: "boolean", default: false })
  published!: boolean;

  @CreateDateColumn({ name: "created_at" })
  createdAt!: Date;

  @UpdateDateColumn({ name: "updated_at" })
  updatedAt!: Date;
}

The generated-column decorators are worth memorising: @CreateDateColumn sets a timestamp on insert, @UpdateDateColumn refreshes it on every update, and @DeleteDateColumn enables soft deletes. @PrimaryGeneratedColumn("uuid") produces a UUID rather than an auto-incrementing integer, which is useful when IDs are exposed publicly.

For money, store integer minor units in an integer or bigint column and never a float. For genuinely optional attributes, a jsonb column with { type: "jsonb" } is fine, but anything you filter or constrain belongs in its own column.

The DataSource and configuration

Since version 0.3, a DataSource is the single object that holds connection options and hands out repositories, managers and transactions.

import "reflect-metadata";
import { DataSource } from "typeorm";

export const AppDataSource = new DataSource({
  type: "postgres",
  url: process.env.DATABASE_URL,
  entities: ["src/entities/*.entity.ts"],
  migrations: ["src/migrations/*.ts"],
  synchronize: false,
  logging: ["error", "warn"],
});

The reflect-metadata import must come before any entity is loaded, because decorators rely on it. In a compiled build, point entities and migrations at the emitted .js files instead of the .ts sources.

Keep synchronize off everywhere except a throwaway local database. It rewrites the schema to match your entities on boot, which means a renamed property can silently drop a column and its data. Migrations exist precisely so that schema changes are reviewed, versioned and repeatable.

Repositories and the find API

A repository is a typed gateway to one entity. You get one from the DataSource and use it for the majority of your reads and writes.

const users = AppDataSource.getRepository(User);

const user = await users.findOneBy({ id });
const recent = await users.find({
  where: { email: ILike("%@example.com") },
  order: { createdAt: "DESC" },
  take: 20,
  skip: 0,
});

find returns an array and findOne returns a single row or null. The options object accepts where, order, take, skip, select, relations and withDeleted. Operators such as ILike, In, MoreThan and IsNull live in the typeorm package and compose inside where.

Writes use create, save and remove:

const user = users.create({ email, displayName });
await users.save(user);

user.displayName = "Ada Lovelace";
await users.save(user);

save performs an upsert: it inserts when the primary key is absent and updates when it is present. That convenience hides whether a row was created or changed, so when the distinction matters, use insert and update directly.

Relations and how they load

Relations are declared on both sides. The @ManyToOne side owns the foreign key, and @OneToMany is its inverse.

@Entity("posts")
export class Post {
  @ManyToOne(() => User, (user) => user.posts, { onDelete: "CASCADE" })
  @JoinColumn({ name: "author_id" })
  author!: User;

  @Column({ name: "author_id" })
  authorId!: string;
}

@Entity("users")
export class User {
  @OneToMany(() => Post, (post) => post.author)
  posts!: Post[];
}

Keeping an explicit authorId column beside the relation is a common and useful pattern: you can read and filter by the foreign key without loading the related row.

@ManyToMany needs a join table, declared with @JoinTable on the owning side. @OneToOne works like @ManyToOne but enforces uniqueness.

Loading is the part that matters. By default relations are not loaded. You must ask with the relations option, an eager flag, or a QueryBuilder join.

const posts = await AppDataSource.getRepository(Post).find({
  relations: { author: true },
  where: { published: true },
});

Eager relations load automatically on every find, and lazy relations return a promise that triggers a query on access. Both are convenient and both hide query counts. Prefer explicit loading, especially on hot paths, so the cost of a query is visible where it is written.

The QueryBuilder for real queries

The QueryBuilder covers joins, aggregates and dynamic filters that the find API cannot express cleanly.

const posts = await AppDataSource.getRepository(Post)
  .createQueryBuilder("post")
  .innerJoinAndSelect("post.author", "author")
  .where("author.email = :email", { email })
  .andWhere("post.createdAt >= :since", { since })
  .orderBy("post.createdAt", "DESC")
  .take(20)
  .getMany();

Aliases are mandatory and parameters are always bound with :name placeholders, never string interpolation. getMany returns entities, getRawMany returns plain rows, and getOne or getManyAndCount cover the common single-row and pagination cases.

Dynamic filters build up naturally:

const qb = repo.createQueryBuilder("post").where("post.published = true");

if (tag) {
  qb.andWhere("post.tags @> :tag", { tag: [tag] });
}
if (search) {
  qb.andWhere("post.title ILIKE :search", { search: `%${search}%` });
}

When a query is too specialised for the builder, dataSource.query() runs parameterised SQL directly and returns raw rows.

Migrations: generating and running

Migrations are the only safe way to change a production schema. TypeORM can generate them by comparing your entities to the live database.

npx typeorm migration:generate src/migrations/CreateUsers -d src/data-source.ts
npx typeorm migration:run -d src/data-source.ts
npx typeorm migration:revert -d src/data-source.ts

The generated file contains up and down methods. Read them before committing: the diff is a best guess and occasionally produces destructive statements you did not intend.

import { MigrationInterface, QueryRunner } from "typeorm";

export class CreateUsers1710000000000 implements MigrationInterface {
  async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      CREATE TABLE users (
        id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
        email        text NOT NULL UNIQUE,
        display_name text NOT NULL,
        created_at   timestamptz NOT NULL DEFAULT now()
      )
    `);
  }

  async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP TABLE users`);
  }
}

Run migrations as a dedicated deploy step with a role that can perform DDL, separate from the role the application uses at runtime. Never let the application alter its own schema on boot.

Transactions

A transaction groups statements so they either all succeed or all roll back. The DataSource.transaction helper manages the commit, rollback and connection for you.

await AppDataSource.transaction(async (manager) => {
  const user = await manager.save(User, { email, displayName });
  await manager.save(Post, { title, body, author: user });
});

If the callback throws, TypeORM rolls back and rethrows. Use the provided manager for every statement inside the block; a repository obtained from the DataSource directly uses a different connection and would run outside the transaction.

For finer control, createQueryRunner exposes startTransaction, commitTransaction and rollbackTransaction. That is useful when the transaction spans code that cannot be expressed as one callback, but keep such transactions short: holding one open across an HTTP call blocks vacuum and invites lock contention.

Avoiding the N+1 problem

The N+1 problem is the most common performance bug in ORM code. You load a list of posts, then loop over it and access post.author, producing one query for the list plus one per post.

// N+1: one query for posts, then one per author.
const posts = await repo.find();
for (const post of posts) {
  console.log(post.author.email);
}

The fix is to load the relation up front in a single query:

const posts = await repo.find({ relations: { author: true } });

When you only need a count, avoid loading the children at all:

const users = await AppDataSource.getRepository(User)
  .createQueryBuilder("user")
  .loadRelationCountAndMap("user.postCount", "user.posts")
  .getMany();

Turn on query logging in development and read the log after building a page. If a request issues dozens of near-identical queries, you have an N+1 problem and the relations option or a join is the cure.

TypeORM in NestJS

@nestjs/typeorm integrates the ORM with Nest’s dependency injection. The root module holds the connection, and feature modules request repositories.

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "postgres",
      url: process.env.DATABASE_URL,
      autoLoadEntities: true,
      synchronize: false,
    }),
    TypeOrmModule.forFeature([User, Post]),
  ],
})
export class AppModule {}

A service then receives its repository through the constructor:

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly users: Repository<User>,
  ) {}

  findByEmail(email: string) {
    return this.users.findOneBy({ email });
  }
}

autoLoadEntities registers entities that were passed to forFeature, which keeps the root config free of a long import list. Keep migrations in the Nest CLI or a standalone script rather than in application startup.

Active Record or Data Mapper?

The choice is mostly about where persistence lives. Active Record is shorter for simple models, because the entity carries its own query methods.

@Entity("users")
export class User extends BaseEntity {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  email!: string;
}

const user = await User.findOneBy({ id });

Data Mapper keeps entities as plain data and moves all persistence into repositories:

const users = AppDataSource.getRepository(User);
const user = await users.findOneBy({ id });

Active Record is convenient in scripts and small services, but it couples the model to the ORM and makes testing harder. Data Mapper is the better default for application code, and it is what NestJS encourages. Pick one style per project rather than mixing them, because consistency matters more than the specific choice.

Best practices

  • Keep synchronize off and manage every schema change with a reviewed migration.
  • Load relations explicitly with relations or a QueryBuilder join, and log queries in development.
  • Use @Column({ type: ... }) for anything where the inferred type could be wrong.
  • Keep the authorId foreign-key column beside the relation for cheap filtering.
  • Prefer Data Mapper repositories for application code and reserve Active Record for scripts.
  • Use dataSource.transaction and always pass the transaction manager into nested calls.
  • Bind every value as a parameter; never interpolate user input into SQL.
  • Add indexes for the columns you filter and join on, and confirm them with EXPLAIN ANALYZE.
  • Run migrations with a dedicated DDL role, separate from the application’s runtime role.

Common mistakes

  • Leaving synchronize: true on a shared or production database.
  • Accessing a lazy relation inside a loop and creating an N+1 query storm.
  • Forgetting the reflect-metadata import, which breaks decorator metadata at runtime.
  • Using the DataSource repository inside a transaction instead of the provided manager.
  • Assuming the generated migration is always safe; it can drop columns on a rename.
  • Typing a column as string and letting TypeORM infer varchar(255) unexpectedly.
  • Storing money as a float and losing precision.
  • Treating findOne as if it throws; it returns null when nothing matches.
  • Mixing Active Record and Data Mapper patterns across the same entity.

Where to go next

TypeORM is a comfortable home if you work in NestJS, and understanding it makes the alternatives easier to judge. Read the Prisma guide for a schema-first ORM with a generated client, or Drizzle ORM if you want a thin layer that keeps SQL front and centre. To go deeper on the database itself, the PostgreSQL guide covers indexes, transactions and the planner, and NestJS shows how to structure the services that wrap your repositories.

In practice

Entities, repositories, QueryBuilder, transactions

The four layers you use every day, from the class definition down to an atomic write.

src/entities/post.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToOne,
  JoinColumn,
  CreateDateColumn,
} from "typeorm";
import { User } from "./user.entity";

@Entity("posts")
export class Post {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  title!: string;

  @Column({ type: "text" })
  body!: string;

  @ManyToOne(() => User, (user) => user.posts, {
    onDelete: "CASCADE",
  })
  @JoinColumn({ name: "author_id" })
  author!: User;

  @Column({ name: "author_id" })
  authorId!: string;

  @CreateDateColumn({ name: "created_at" })
  createdAt!: Date;
}

Repository methods vs the QueryBuilder

Start with repositories because they are typed and hard to get wrong. Reach for the QueryBuilder when a query needs joins, aggregates or dynamic filters.

Repository
const users = await repo.find({
  where: { email: "[email protected]" },
  order: { createdAt: "DESC" },
  take: 10,
});
QueryBuilder
const users = await repo
  .createQueryBuilder("user")
  .leftJoinAndSelect("user.posts", "post")
  .where("user.email = :email", { email })
  .orderBy("user.createdAt", "DESC")
  .take(10)
  .getMany();

Eager relations vs explicit loading

Eager loading is convenient but it runs on every find and can quietly fan out into many queries. Explicit relations keep the cost visible at the call site.

Prefer
// Explicit: one clear extra join, chosen per query.
const posts = await repo.find({
  relations: { author: true },
  where: { status: "published" },
});
Avoid
// Eager on the entity: loads on every single find,
// including the ones that never need the author.
@ManyToOne(() => User, { eager: true })
author!: User;

Trade-offs

Should you pick TypeORM for a new project?

TypeORM is capable and familiar to NestJS teams, but the ecosystem has moved on in places. Weigh the ergonomics against the alternatives.

Strengths

  • Familiar to NestJS teams

    @nestjs/typeorm wires repositories into the dependency injector, so entities and services feel like native Nest code.

  • Both ORM patterns in one library

    You can use Data Mapper repositories for most code and Active Record for simple models, without adopting a second tool.

  • Rich relations and migrations

    Four relation types, cascades, subscribers and schema-diff migrations cover a wide range of relational modelling.

Trade-offs

  • Silent relation loading

    Lazy and eager relations make it easy to trigger queries you did not intend, which is how N+1 problems appear in production.

  • Typing can be imprecise

    find where clauses and the QueryBuilder accept strings, so some mistakes only surface at runtime rather than at compile time.

  • Maintenance is steady, not fast

    Development is slower than in Prisma or Drizzle, and open issues can linger. Check the repository before committing long term.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning TypeORM?

Our interactive tutorial walks you through TypeORM step by step — with quizzes and real code you can run in the browser.