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
synchronizeoff and manage every schema change with a reviewed migration. - Load relations explicitly with
relationsor a QueryBuilder join, and log queries in development. - Use
@Column({ type: ... })for anything where the inferred type could be wrong. - Keep the
authorIdforeign-key column beside the relation for cheap filtering. - Prefer Data Mapper repositories for application code and reserve Active Record for scripts.
- Use
dataSource.transactionand always pass the transactionmanagerinto 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: trueon a shared or production database. - Accessing a lazy relation inside a loop and creating an N+1 query storm.
- Forgetting the
reflect-metadataimport, which breaks decorator metadata at runtime. - Using the
DataSourcerepository 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
stringand letting TypeORM infervarchar(255)unexpectedly. - Storing money as a float and losing precision.
- Treating
findOneas if it throws; it returnsnullwhen 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.