TypeORM with TypeScript: Entities, Repositories, Relations, Migrations and Transactions
Key takeaways
TypeORM is a TypeScript-first ORM for Node.js that works with PostgreSQL, MySQL, SQLite, and more. It provides decorators, migrations, relations, and full type safety.
Introduction
TypeORM is an ORM (Object-Relational Mapping) library that brings SQL databases to TypeScript and Node.js with decorators, repositories, migrations, and full type safety.
What makes TypeORM different from lighter query builders like Kysely, or schema-first tools like Prisma, is that it tries to give you both of the classic ORM architectures in one library: Active Record (where the entity class itself carries save(), remove(), and find() methods, similar to Rails or Laravel’s Eloquent) and Data Mapper (where entities are plain data containers and a separate Repository object does the persistence work). This is not a cosmetic choice — it changes how your codebase is structured, how testable your models are, and how much coupling you end up with between business logic and the database. Most production TypeORM codebases, including the one this guide uses, standardize on Data Mapper via Repository, because it keeps entities free of database concerns and makes them trivial to unit test without spinning up a real connection. Active Record is faster to prototype with, but it quietly ties every entity class to a live DataSource, which becomes painful once you need to mock persistence in tests or swap connections (e.g., a read replica) at runtime.
Why TypeORM?
Raw SQL:
const result = await db.query('SELECT * FROM users WHERE email = $1', [email]);
const user = result.rows[0]; // No types
With TypeORM:
@Entity()
class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
}
const user = await userRepository.findOne({ where: { email } }); // Fully typed!
The raw SQL version above isn’t just verbose — it’s a maintenance liability. The shape of result.rows[0] exists only in the developer’s head; if a column is renamed or a query is edited, nothing in the type system will catch the mismatch until it fails at runtime. TypeORM’s decorator-based entities move that contract into code: the User class is the schema, the compiler enforces it everywhere the entity is used, and refactoring tools can trace every usage. The trade-off is that decorators require experimentalDecorators and emitDecoratorMetadata in tsconfig.json, plus a reflect-metadata import at your application’s entry point — skipping either of these is the single most common “why isn’t TypeORM working” bug reported in the wild.
Installation
npm install typeorm reflect-metadata
npm install -D @types/node
# Database driver (choose one)
npm install pg # PostgreSQL
npm install mysql2 # MySQL
npm install sqlite3 # SQLite
Configuration
// src/data-source.ts
import { DataSource } from 'typeorm';
import { User } from './entities/User';
export const AppDataSource = new DataSource({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'password',
database: 'myapp',
entities: [User],
synchronize: true, // Auto-create tables (dev only!)
logging: true,
});
// Initialize
await AppDataSource.initialize();
The synchronize: true line deserves more attention than a single inline comment. It tells TypeORM to inspect your entity classes on every startup and automatically alter the database schema (add columns, create tables, even drop columns it thinks are unused) to match them. This is genuinely convenient in local development — you change an entity, restart the process, and the table is already updated. In production, however, it is one of the best-documented footguns in the ORM world. synchronize has no concept of “this column has real data in it”; if you rename a field or narrow a type, it can silently drop and recreate columns, destroying data with no confirmation prompt and no rollback path. Because it runs automatically on initialize(), a bad entity change deployed alongside application code will mutate your production schema the moment the new process boots — before you’ve had a chance to review a migration diff. The fix is simple in principle and easy to forget in practice: gate it behind an environment check (synchronize: process.env.NODE_ENV !== 'production') from day one, and rely on generated migrations (covered in the Migrations section) for anything that touches a real database.
logging: true is worth keeping on early in a project too, but for a different reason: it prints every generated SQL statement, which is the fastest way to notice N+1 query patterns (see Relations) before they become a production incident. Just remember to turn it off, or scope it to ['error', 'warn'], once you’re past initial development — verbose SQL logging on every request is a real cost in high-traffic services.
Entity Definition
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('users') // Table name
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column()
name: string;
@Column({ nullable: true })
bio: string | null;
@Column({ type: 'int', default: 0 })
age: number;
@Column({ type: 'varchar', length: 50 })
username: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
A few of these column decorators look interchangeable but aren’t. @Column() with no options infers the SQL type from the TypeScript type — a string property becomes varchar, a number becomes integer — which is convenient but can bite you: TypeScript’s number covers both integers and floats, so TypeORM’s inference for a plain @Column() price: number will pick integer on some drivers, silently truncating decimals unless you’re explicit with @Column({ type: 'decimal', precision: 10, scale: 2 }). Similarly, nullable: true only changes the database constraint (NOT NULL vs nullable) — it does not automatically widen the TypeScript type to | null unless you write that yourself, as the bio field does above. Forgetting the union type is a common source of “why did this come back null when the type says string” bugs that only surface at runtime.
@CreateDateColumn() and @UpdateDateColumn() are populated by TypeORM itself, not the database, which matters if you ever insert rows through raw SQL or another tool — those timestamps won’t be set unless the database also has a default. If you need the database to own these timestamps (for correctness under concurrent writes, or so tools that bypass the ORM still get correct values), define them as regular @Column({ default: () => 'CURRENT_TIMESTAMP' }) instead.
Repository Pattern
import { AppDataSource } from './data-source';
import { User } from './entities/User';
const userRepository = AppDataSource.getRepository(User);
// Create
const user = userRepository.create({
email: '[email protected]',
name: 'Alice',
});
await userRepository.save(user);
// Find all
const users = await userRepository.find();
// Find one
const user = await userRepository.findOne({
where: { email: '[email protected]' }
});
// Find with conditions
const activeUsers = await userRepository.find({
where: { isActive: true }
});
// Update
await userRepository.update(
{ id: 1 },
{ name: 'Alice Updated' }
);
// Delete
await userRepository.delete({ id: 1 });
// Count
const count = await userRepository.count();
Two lines here look almost identical but behave very differently, and mixing them up is a recurring source of confusion for developers new to TypeORM. userRepository.create({...}) does not touch the database — it just instantiates a plain User object (running any default values and class-transformer logic along the way). Nothing is persisted until you call .save() on the result. This two-step split exists because Data Mapper repositories keep “build the object” and “persist the object” as separate concerns, which is exactly what makes entities easy to construct and validate in tests without a database connection at all.
.save() itself is deceptively powerful: pass it an entity with a primary key that already exists in the table, and it performs an UPDATE; pass it one without a matching key, and it performs an INSERT. That “upsert-like” convenience is handy for simple flows, but in code paths where you specifically mean “this must be a new row” or “this must already exist,” prefer the more explicit .insert() / .update() methods — they fail loudly instead of silently doing the wrong operation if a stray id field slips into the payload (a classic bug when an entity object is round-tripped through an API request body that a client controls).
// Complex query
const users = await userRepository
.createQueryBuilder('user')
.where('user.age > :age', { age: 18 })
.andWhere('user.isActive = :isActive', { isActive: true })
.orderBy('user.createdAt', 'DESC')
.limit(10)
.getMany();
// With relations
const users = await userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.where('post.published = :published', { published: true })
.getMany();
// Aggregate
const result = await userRepository
.createQueryBuilder('user')
.select('COUNT(*)', 'count')
.where('user.isActive = :isActive', { isActive: true })
.getRawOne();
The Query Builder exists because the find() / findOne() options API, while type-safe and readable, can’t express everything SQL can — raw aggregates, subqueries, UNIONs, or conditional JOINs with custom ON clauses. The trade-off is that query builder strings like 'user.age > :age' are no longer checked by the compiler; a typo in a column name fails only at query execution time, against a live database. A practical rule that keeps a codebase consistent: default to the repository’s find() options for anything expressible that way, and reach for createQueryBuilder only when you hit its limits — mixing both styles freely across a codebase makes it harder to reason about which queries are type-checked and which aren’t.
Relations
Relations are where TypeORM’s convenience most easily turns into a production performance problem, so it’s worth understanding what actually happens on the wire before writing relation-heavy code.
sequenceDiagram
participant App as Application code
participant TypeORM
participant DB as Database
Note over App,DB: Eager-loaded relation (one query)
App->>TypeORM: find(User, { relations: ['posts'] })
TypeORM->>DB: SELECT * FROM users LEFT JOIN posts ...
DB-->>TypeORM: joined rows
TypeORM-->>App: User[] with posts populated
Note over App,DB: N+1 pattern (many queries)
App->>TypeORM: find(User) then loop user.posts
TypeORM->>DB: SELECT * FROM users
DB-->>TypeORM: User[]
loop for each user
App->>TypeORM: access lazy user.posts
TypeORM->>DB: SELECT * FROM posts WHERE authorId = ?
DB-->>TypeORM: posts for one user
end
One-to-Many
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
}
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@ManyToOne(() => User, (user) => user.posts)
author: User;
}
// Usage
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['posts'], // Load posts
});
console.log(user.posts); // Post[]
Notice that relations: ['posts'] is opt-in and explicit here — you have to ask for it. That’s deliberate: TypeORM relations default to lazy unless you either request them via relations: [...] / .leftJoinAndSelect(...), or mark the property eager: true in the decorator. Forgetting to request a relation doesn’t throw an error; user.posts simply comes back undefined, which is a common source of confused bug reports (“the relation works in one query but not another”). The more damaging version of this mistake shows up when a relation is requested, but inside a loop: calling findOne({ relations: ['posts'] }) once per user inside a for loop over 100 users issues 1 query for the users plus 100 more for their posts — the classic N+1 problem. The sequence diagram above shows the difference: a single LEFT JOIN (or a batched relations load) costs one round trip regardless of row count, while accessing a lazy relation per-entity costs one round trip per entity. For read-heavy endpoints, always fetch relations in the same query (relations: [...] or leftJoinAndSelect) rather than triggering lazy loads inside a loop, and set eager: true only for relations you genuinely need on nearly every query — marking everything eager just moves the N+1 cost into a single, permanently bloated JOIN.
Many-to-Many
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@ManyToMany(() => Tag, (tag) => tag.posts)
@JoinTable() // Creates join table
tags: Tag[];
}
@Entity()
export class Tag {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@ManyToMany(() => Post, (post) => post.tags)
posts: Post[];
}
// Usage
const post = await postRepository.findOne({
where: { id: 1 },
relations: ['tags'],
});
console.log(post.tags); // Tag[]
The @JoinTable() decorator matters here for a subtle reason: it must appear on exactly one side of a many-to-many relation — the “owning” side — because that’s the entity TypeORM uses to generate and manage the physical join table (post_tags_tag by default). Put @JoinTable() on both sides, or on neither, and you’ll get either a duplicate-table migration error or a relation that TypeORM can’t persist changes to. It’s also worth knowing that by default, saving a Post with a new tags array does not delete tag associations that were removed from the array — you need cascade: true (to let saving the parent also insert/update related rows) and, in some versions, explicit handling for removed associations, or you’ll accumulate stale join-table rows that “add” but never “remove.”
One-to-One
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToOne(() => Profile, (profile) => profile.user)
@JoinColumn()
profile: Profile;
}
@Entity()
export class Profile {
@PrimaryGeneratedColumn()
id: number;
@Column()
bio: string;
@OneToOne(() => User, (user) => user.profile)
user: User;
}
One-to-one relations need @JoinColumn() on exactly one side too — whichever entity holds the foreign key column. It’s easy to assume the decorator placement doesn’t matter since the relationship is conceptually symmetric, but TypeORM needs to know which table physically stores the profileId (or userId) column, and only the owning side generates it.
Migrations
Generate Migration
# Auto-generate from entity changes (the name is a path; -n was removed in 0.3)
npx typeorm-ts-node-commonjs migration:generate src/migrations/CreateUserTable -d src/data-source.ts
# Create empty migration
npx typeorm migration:create src/migrations/CreateUserTable
This is where synchronize and migrations connect: migration:generate works by diffing your current entities against the actual database schema and writing the SQL needed to reconcile them. That means it needs a real, reachable database connection with a schema that reflects the last-applied migration — if synchronize was ever left on and silently drifted the schema out of sync with your migration history, migration:generate will produce a diff against that drifted state, not against what your migrations say the schema should be. This is precisely why teams that use migrations seriously disable synchronize everywhere, including local development once the schema stabilizes, so that the migration history stays the single source of truth. Auto-generated migrations should also always be reviewed by hand before running — TypeORM does not understand your data, so a column rename shows up as a “drop old column, add new column” pair, which would silently destroy existing data on a real table with rows in it.
Migration File
// src/migrations/1234567890-CreateUserTable.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserTable1234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "users" (
"id" SERIAL PRIMARY KEY,
"email" VARCHAR UNIQUE NOT NULL,
"name" VARCHAR NOT NULL,
"createdAt" TIMESTAMP DEFAULT NOW()
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "users"`);
}
}
Run Migrations
# Run migrations
npx typeorm migration:run -d src/data-source.ts
# Revert last migration
npx typeorm migration:revert -d src/data-source.ts
# Show migration status
npx typeorm migration:show -d src/data-source.ts
Each migration file’s up() and down() methods should be true inverses of each other — down() exists specifically so a bad deploy can be rolled back without a manual database intervention at 2 a.m. It’s tempting to leave down() as a stub (throw new Error('not implemented')) when writing a migration under time pressure, but that decision only becomes visible as a problem during an actual incident, when reverting is exactly what you need and can’t do. In CI/CD, migrations should run as a distinct deploy step before the new application code starts serving traffic — running them lazily on first request, or relying on synchronize, means different application instances can briefly see different schemas during a rolling deploy.
Transactions
await AppDataSource.transaction(async (manager) => {
// Create user
const user = manager.create(User, {
email: '[email protected]',
name: 'Alice',
});
await manager.save(user);
// Create post
const post = manager.create(Post, {
title: 'My First Post',
author: user,
});
await manager.save(post);
// Both saved or both rolled back
});
The critical detail in this example is easy to miss: both save() calls use the manager passed into the callback, not AppDataSource.getRepository(...). The transaction-scoped manager is what ties both writes to the same underlying database transaction; if you accidentally call userRepository.save(user) using the module-level repository instead of manager.save(User, user) inside the callback, that write runs outside the transaction, on its own connection, and won’t roll back if the post save fails afterward. This is one of the most common TypeORM transaction bugs, precisely because both calls look nearly identical and TypeScript won’t flag the mistake — the entity manager and the repository share the same method signatures.
It’s also worth knowing what “rolled back” means operationally: if an error is thrown anywhere inside the callback, TypeORM issues a ROLLBACK and re-throws the original error to the caller — it does not swallow it. Code calling AppDataSource.transaction(...) still needs its own try/catch (or an async framework’s error middleware) to handle that rethrown error; a bare await AppDataSource.transaction(...) with no surrounding error handling will crash an unhandled-rejection handler in production rather than returning a clean 500 to the client. For anything beyond two or three related writes, prefer transaction() over manually calling queryRunner.startTransaction() / commitTransaction() / rollbackTransaction() — the manual API is more flexible but requires you to remember to release the query runner’s connection in a finally block, and leaking connections under error paths is a real production failure mode with connection-pooled databases.
Validation
npm install class-validator
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { IsEmail, MinLength, MaxLength, Min, Max } from 'class-validator';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
@IsEmail()
email: string;
@Column()
@MinLength(2)
@MaxLength(50)
name: string;
@Column()
@Min(0)
@Max(120)
age: number;
}
// Validate before save
import { validate } from 'class-validator';
const user = new User();
user.email = 'invalid-email';
const errors = await validate(user);
if (errors.length > 0) {
console.log('Validation failed:', errors);
}
class-validator decorators like @IsEmail() and @Min() describe application-level validation — they run when you explicitly call validate(user), typically inside a request handler or a framework integration like NestJS’s ValidationPipe. They are not enforced by the database and are not automatically run before save(). This is an important distinction from database constraints: a @Column({ unique: true }) is enforced by the database itself (and will throw a driver-level unique-violation error if you skip validation and insert a duplicate), while @IsEmail() only ever catches bad input if something in your code path remembers to call validate() first. Relying on class-validator alone, without matching database constraints for anything that must hold true regardless of code path (uniqueness, non-null, foreign key integrity), leaves a gap that concurrent requests or a forgotten validation call can slip through.
Custom Repository
// TypeORM 0.3+
export const UserRepository = AppDataSource.getRepository(User).extend({
findByEmail(email: string) {
return this.findOne({ where: { email } });
},
findActiveUsers() {
return this.find({ where: { isActive: true } });
},
findWithPosts(userId: number) {
return this.findOne({
where: { id: userId },
relations: ['posts'],
});
},
});
// Usage
const user = await UserRepository.findByEmail('[email protected]');
Many tutorials and Stack Overflow answers still show the older pattern: a class decorated with @EntityRepository(User) that extends Repository<User>, fetched with getCustomRepository(). Both were deprecated in TypeORM 0.3 (2022), when Connection was replaced by DataSource, and do not fit the DataSource API; Repository.extend() shown above is the replacement. Inside the object passed to extend, this is the repository, so use regular methods rather than arrow functions, which would lose it. If you see the decorator in an existing codebase, npm ls typeorm tells you which major version you are on before you start mixing the two styles. Plain functions that accept a Repository<User> are an equally valid alternative and are easier to mock in tests.
Subscribers (Hooks)
import { EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent } from 'typeorm';
import { User } from './entities/User';
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
listenTo() {
return User;
}
beforeInsert(event: InsertEvent<User>) {
console.log('Before user insert:', event.entity);
}
afterInsert(event: InsertEvent<User>) {
console.log('After user insert:', event.entity);
}
beforeUpdate(event: UpdateEvent<User>) {
console.log('Before user update:', event.entity);
}
afterUpdate(event: UpdateEvent<User>) {
console.log('After user update:', event.entity);
}
}
Subscribers are a global cross-cutting mechanism — once registered with the DataSource, UserSubscriber fires for every insert and update of User anywhere in the application, including ones triggered indirectly through cascading saves on a relation. That makes subscribers a good fit for genuinely cross-cutting concerns like audit logging, cache invalidation, or search-index syncing, but a poor fit for business logic that only applies in specific request flows — logic buried in a subscriber is invisible at the call site, which makes it easy for a future maintainer (including future you) to be surprised by side effects they didn’t expect when calling save(). A practical guideline: if the logic needs to know why an entity changed, put it in the calling code; if it only needs to know that it changed, a subscriber is appropriate.
Real-World Example: Blog API
// entities/User.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn } from 'typeorm';
import { Post } from './Post';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column()
username: string;
@Column()
password: string;
@Column({ default: 'user' })
role: 'user' | 'admin';
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
@CreateDateColumn()
createdAt: Date;
}
// entities/Post.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, ManyToMany, JoinTable, CreateDateColumn } from 'typeorm';
import { User } from './User';
import { Tag } from './Tag';
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column({ unique: true })
slug: string;
@Column('text')
content: string;
@Column({ default: false })
published: boolean;
@ManyToOne(() => User, (user) => user.posts)
author: User;
@ManyToMany(() => Tag, (tag) => tag.posts)
@JoinTable()
tags: Tag[];
@CreateDateColumn()
createdAt: Date;
}
// entities/Tag.ts
@Entity('tags')
export class Tag {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
name: string;
@ManyToMany(() => Post, (post) => post.tags)
posts: Post[];
}
// Express API
import express from 'express';
import { AppDataSource } from './data-source';
import { Post } from './entities/Post';
import { User } from './entities/User';
const app = express();
app.use(express.json());
const postRepository = AppDataSource.getRepository(Post);
const userRepository = AppDataSource.getRepository(User);
// Get all posts
app.get('/api/posts', async (req, res) => {
try {
const posts = await postRepository.find({
where: { published: true },
relations: ['author', 'tags'],
order: { createdAt: 'DESC' },
});
res.json({ posts });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get single post
app.get('/api/posts/:slug', async (req, res) => {
try {
const post = await postRepository.findOne({
where: { slug: req.params.slug },
relations: ['author', 'tags'],
});
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
res.json({ post });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create post
app.post('/api/posts', authenticate, async (req, res) => {
try {
const { title, content, tagNames } = req.body;
const author = await userRepository.findOne({ where: { id: req.user.id } });
const post = postRepository.create({
title,
slug: title.toLowerCase().replace(/\s+/g, '-'),
content,
author,
});
await postRepository.save(post);
res.status(201).json({ post });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// Update post
app.put('/api/posts/:id', authenticate, async (req, res) => {
try {
const post = await postRepository.findOne({
where: { id: parseInt(req.params.id) },
relations: ['author'],
});
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
if (post.author.id !== req.user.id) {
return res.status(403).json({ error: 'Unauthorized' });
}
Object.assign(post, req.body);
await postRepository.save(post);
res.json({ post });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
await AppDataSource.initialize();
app.listen(3000);
This example is deliberately structured to show a full request lifecycle: authenticate, look up the acting user, then check post.author.id !== req.user.id before allowing an update. That ownership check only works because relations: ['author'] was requested on the findOne() call above it — if that relation were forgotten, post.author would be undefined, post.author.id would throw a TypeError at runtime, and the endpoint would 500 instead of correctly returning a 403 or 404. This is a good illustration of why relation loading isn’t just a performance concern (see Relations) but a correctness one: authorization logic that reads through a relation is silently broken if that relation isn’t eagerly fetched in the same query. It’s also worth noting the slug generation (title.toLowerCase().replace(/\s+/g, '-')) is naive — it doesn’t strip punctuation or handle Unicode, and because slug is unique: true, two posts with titles that normalize to the same slug will fail on save() with a database constraint error rather than a friendly validation message; production code should either use a dedicated slugify library or catch that specific driver error and surface it as a 409 Conflict.
Soft Deletes
import { Entity, Column, DeleteDateColumn } from 'typeorm';
@Entity()
export class User {
@Column()
name: string;
@DeleteDateColumn()
deletedAt?: Date; // Soft delete timestamp
}
// Soft delete
await userRepository.softDelete({ id: 1 });
// Find with deleted
const users = await userRepository.find({ withDeleted: true });
// Restore
await userRepository.restore({ id: 1 });
// Hard delete
await userRepository.delete({ id: 1 });
Soft deletes are useful for audit trails, “undo” features, and satisfying data-retention requirements that forbid immediately erasing records, but they come with a cost that’s easy to overlook: every other query in the codebase must now remember that deletedAt IS NULL is an implicit filter. TypeORM adds this automatically to find()/findOne() on entities with a @DeleteDateColumn(), but any raw SQL, any query builder call that bypasses the standard find methods, or any COUNT(*) run directly against the table will include soft-deleted rows unless you filter for them explicitly. Unique constraints are the sharpest edge here: if email is unique: true and a user is soft-deleted, that email address is still considered “taken” at the database level, so the same person can’t sign up again with the same address unless the unique index is scoped to also check deletedAt (a partial/conditional unique index, which not all databases support the same way) or the application layer restores the old row instead of inserting a new one.
Indexing
import { Entity, Column, Index } from 'typeorm';
@Entity()
@Index(['email', 'username']) // Compound index
export class User {
@Column()
@Index() // Single column index
email: string;
@Column()
@Index({ unique: true }) // Unique index
username: string;
@Column()
@Index('idx_name') // Named index
name: string;
}
Decorator-defined indexes only take effect through migrations (or synchronize, in dev) — adding @Index() to an entity does nothing to an already-running production database until a migration that creates the index is generated and applied. It’s also worth remembering that indexes aren’t free: each one speeds up reads that filter or sort on that column but adds overhead to every INSERT/UPDATE/DELETE, since the index has to be maintained alongside the table. A common mistake is indexing columns defensively “just in case” rather than based on actual query patterns — EXPLAIN ANALYZE on your slowest real queries is a far better guide to what needs an index than guessing from the entity definition alone.
Running migrations from the compiled build
# Generate a migration from entity changes (TypeScript sources, via ts-node)
npx typeorm-ts-node-commonjs migration:generate src/migrations/AddUserRole -d src/data-source.ts
# Run in production against the compiled output
npx typeorm migration:run -d dist/data-source.js
Note that migration:run is pointed at dist/data-source.js here, not the .ts source — in production you’re running compiled JavaScript, and the DataSource config, entity paths, and migration paths all need to resolve correctly against the build output, not the source tree. A common deployment mistake is a data-source.ts whose entities/migrations globs (e.g. src/migrations/*.ts) never get updated for the compiled dist/migrations/*.js equivalent, so migrations that work locally silently find zero files to run in production and migration:run reports “No migrations are pending” even when there are unapplied ones — always verify with migration:show against the actual production build artifacts, not just locally.
The plain typeorm CLI cannot load a .ts data source by itself, which is why generation during development goes through the typeorm-ts-node-commonjs wrapper (or typeorm-ts-node-esm for ES module projects). In TypeORM 0.3 the migration name is a positional path, not a -n flag; the old -n Name form from 0.2 tutorials fails with an argument error.
Frequently Asked Questions (FAQ)
Q. Why does save() sometimes run an extra SELECT before the insert or update?
A. repository.save() has to decide whether the entity already exists, so when the entity has a primary key it loads the row first and then issues an INSERT or UPDATE. That is convenient but costs an extra round trip, which adds up in bulk operations. When you already know the operation, use insert() or update() directly, or the query builder for batch writes; note that these skip entity listeners and cascades that save() would trigger.
Related Articles
- Drizzle ORM Basics
- tRPC: End-to-End Type Safety Without Codegen, and Where the Types Stop Protecting You
- Prisma ORM in TypeScript