Skip to content

Advanced_entities


TypeORM provides event-driven hooks to execute code at specific entity lifecycle moments.

Entity Lifecycle Events
================================================================================
Entity Lifecycle
|
+--> load (Entity loaded from database)
|
+--> beforeInsert (Before INSERT)
| |
| v
+--> afterInsert (After INSERT)
|
+--> beforeUpdate (Before UPDATE)
| |
| v
+--> afterUpdate (After UPDATE)
|
+--> beforeRemove (Before DELETE)
| |
| v
+--> afterRemove (After DELETE)
|
+--> beforeSoftRemove (Before soft delete)
| |
| v
+--> afterSoftRemove (After soft delete)
|
+--> beforeRecover (Before recover)
| |
| v
+--> afterRecover (After recover)
================================================================================
import {
Entity,
PrimaryGeneratedColumn,
Column,
BeforeInsert,
AfterInsert,
BeforeUpdate,
AfterUpdate,
BeforeRemove,
AfterRemove,
BeforeSoftRemove,
AfterSoftRemove,
AfterLoad,
} from 'typeorm';
import * as bcrypt from 'bcrypt';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@Column({ select: false })
password: string;
@Column({ nullable: true })
passwordChangedAt: Date;
@Column({ default: false })
isActive: boolean;
@DeleteDateColumn()
deletedAt: Date;
// Called after entity is loaded from database
@AfterLoad()
logLoad() {
console.log(`User ${this.id} loaded`);
}
// Called before INSERT
@BeforeInsert()
async hashPassword() {
if (this.password) {
this.password = await bcrypt.hash(this.password, 10);
}
}
// Called after INSERT
@AfterInsert()
logInsert() {
console.log(`User ${this.email} created with ID ${this.id}`);
}
// Called before UPDATE
@BeforeUpdate()
async updatePasswordChangedAt() {
if (this.password) {
this.password = await bcrypt.hash(this.password, 10);
this.passwordChangedAt = new Date();
}
}
// Called after UPDATE
@AfterUpdate()
logUpdate() {
console.log(`User ${this.id} updated`);
}
// Called before DELETE
@BeforeRemove()
logRemove() {
console.log(`About to delete user ${this.id}`);
}
// Called after DELETE
@AfterRemove()
logRemoved() {
console.log(`User ${this.id} deleted`);
}
// Called before soft delete
@BeforeSoftRemove()
logSoftRemove() {
console.log(`About to soft delete user ${this.id}`);
}
// Called after soft delete
@AfterSoftRemove()
logSoftRemoved() {
console.log(`User ${this.id} soft deleted`);
}
}
src/subscribers/user.subscriber.ts
import { EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent, RemoveEvent } from 'typeorm';
import { User } from '../entities/user.entity';
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
// Specify which entity this subscriber listens to
listenTo() {
return User;
}
// Called before INSERT
beforeInsert(event: InsertEvent<User>) {
console.log(`Before INSERT:`, event.entity);
}
// Called after INSERT
afterInsert(event: InsertEvent<User>) {
console.log(`After INSERT:`, event.entity);
// Send welcome email, log activity, etc.
}
// Called before UPDATE
beforeUpdate(event: UpdateEvent<User>) {
console.log(`Before UPDATE:`, event.entity);
}
// Called after UPDATE
afterUpdate(event: UpdateEvent<User>) {
console.log(`After UPDATE:`, event.entity);
}
// Called before DELETE
beforeRemove(event: RemoveEvent<User>) {
console.log(`Before DELETE:`, event.entity);
}
// Called after DELETE
afterRemove(event: RemoveEvent<User>) {
console.log(`After DELETE:`, event.entity);
}
}
// Register in data-source.ts
import { UserSubscriber } from './subscribers/user.subscriber';
export const AppDataSource = new DataSource({
// ... other options
subscribers: [UserSubscriber],
});

Soft delete marks records as deleted without actually removing them.

Soft Delete Flow
+------------------------------------------------------------------+
| |
| Normal Delete: |
| +-------------------+ |
| | DELETE FROM users | |
| | WHERE id = 1 | |
| +-------------------+ |
| | |
| v |
| +-------------------+ |
| | Row is PERMANENTLY| |
| | removed | |
| +-------------------+ |
| |
+------------------------------------------------------------------+
| |
| Soft Delete: |
| +---------------------------+ |
| | UPDATE users | |
| | SET deleted_at = NOW() | |
| | WHERE id = 1 | |
| +---------------------------+ |
| | |
| v |
| +-------------------+ |
| | Row still exists | |
| | deleted_at is set | |
| +-------------------+ |
| |
+------------------------------------------------------------------+
import { Entity, PrimaryGeneratedColumn, Column, DeleteDateColumn } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
email: string;
// Soft delete column
@DeleteDateColumn()
deletedAt: Date;
}
// Usage
// Soft delete
await userRepository.softDelete(1);
// or
await userRepository.softRemove(user);
// Restore soft deleted entity
await userRepository.restore(1);
// or
await userRepository.recover(user);
// Find including soft deleted
const allUsers = await userRepository.find({
withDeleted: true,
});
// Find only soft deleted
const deletedUsers = await userRepository.find({
withDeleted: true,
where: { deletedAt: Not(IsNull()) },
});

Prevents concurrent updates from overwriting each other.

Optimistic Locking Scenario
+------------------------------------------------------------------+
| |
| Without Locking: |
| |
| User A: Read product (stock: 10) |
| User B: Read product (stock: 10) |
| User A: Update stock to 9 |
| User B: Update stock to 9 <-- Lost update! |
| |
+------------------------------------------------------------------+
| |
| With Optimistic Locking: |
| |
| User A: Read product (stock: 10, version: 1) |
| User B: Read product (stock: 10, version: 1) |
| User A: Update stock to 9, version: 2 |
| User B: Update stock to 9, version: 1 |
| | |
| v |
| +---------------------------+ |
| | ERROR! Version mismatch | |
| | Expected version: 2 | |
| | Got version: 1 | |
| +---------------------------+ |
| |
+------------------------------------------------------------------+
import { Entity, PrimaryGeneratedColumn, Column, VersionColumn } from 'typeorm';
@Entity('products')
export class Product {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
stock: number;
// Version column for optimistic locking
@VersionColumn()
version: number;
}
// Usage
async function updateStock(productId: number, quantity: number) {
try {
const product = await productRepository.findOne({ where: { id: productId } });
product.stock -= quantity;
// If version changed since read, this will throw
await productRepository.save(product);
return product;
} catch (error) {
if (error instanceof OptimisticLockError) {
throw new Error('Product was modified by another user. Please refresh and try again.');
}
throw error;
}
}

Load relations only when accessed, reducing initial query overhead.

import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
@Entity('authors')
export class Author {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
// Lazy relation - returns Promise
@OneToMany(() => Book, book => book.author, { lazy: true })
books: Promise<Book[]>;
}
@Entity('books')
export class Book {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@ManyToOne(() => Author, author => author.books)
author: Author;
}
// Usage
const author = await authorRepository.findOne({ where: { id: 1 } });
// Books not loaded yet
console.log(author.books); // Promise
// Load books when needed
const books = await author.books;
console.log(books); // Array of books

Transform data between application and database formats.

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
// Custom transformer for JSON
class JsonTransformer {
to(value: any): string {
return JSON.stringify(value);
}
from(value: string): any {
return JSON.parse(value);
}
}
// Custom transformer for encryption
class EncryptTransformer {
private secret = 'my-secret-key';
to(value: string): string {
return this.encrypt(value);
}
from(value: string): string {
return this.decrypt(value);
}
private encrypt(text: string): string {
// Encryption logic
return text; // Simplified
}
private decrypt(text: string): string {
// Decryption logic
return text; // Simplified
}
}
// Date transformer
class DateTransformer {
to(value: Date): string | null {
return value ? value.toISOString() : null;
}
from(value: string): Date | null {
return value ? new Date(value) : null;
}
}
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({
type: 'text',
transformer: new JsonTransformer(),
})
preferences: Record<string, any>;
@Column({
type: 'varchar',
transformer: new EncryptTransformer(),
})
sensitiveData: string;
@Column({
type: 'varchar',
transformer: new DateTransformer(),
})
customDate: Date;
}

Embed one entity inside another without creating a separate table.

import { Entity, PrimaryGeneratedColumn, Column, Embedded } from 'typeorm';
// Embedded entity (not a table)
class Address {
@Column()
street: string;
@Column()
city: string;
@Column()
state: string;
@Column()
zipCode: string;
@Column()
country: string;
}
class ContactInfo {
@Column()
phone: string;
@Column()
email: string;
}
// Main entity with embedded entities
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
// Embedded with prefix
@Embedded(() => Address, { prefix: 'address' })
address: Address;
// Embedded with different prefix
@Embedded(() => ContactInfo, { prefix: 'contact' })
contact: ContactInfo;
}
// Generated table:
// CREATE TABLE users (
// id SERIAL PRIMARY KEY,
// name VARCHAR(255),
// address_street VARCHAR(255),
// address_city VARCHAR(255),
// address_state VARCHAR(255),
// address_zipCode VARCHAR(255),
// address_country VARCHAR(255),
// contact_phone VARCHAR(255),
// contact_email VARCHAR(255)
// );
// Usage
const user = new User();
user.name = 'John Doe';
user.address = {
street: '123 Main St',
city: 'New York',
state: 'NY',
zipCode: '10001',
country: 'USA',
};
user.contact = {
phone: '555-1234',
email: 'john@example.com',
};

Store multiple entity types in a single table with a discriminator column.

import { Entity, PrimaryGeneratedColumn, Column, TableInheritance, ChildEntity } from 'typeorm';
// Base entity
@Entity('content')
@TableInheritance({
column: { name: 'type', type: 'varchar', length: 20 },
})
export abstract class Content {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column()
createdAt: Date;
}
// Child entities
@ChildEntity('article')
export class Article extends Content {
@Column({ type: 'text' })
body: string;
}
@ChildEntity('video')
export class Video extends Content {
@Column()
duration: number;
@Column()
url: string;
}
@ChildEntity('podcast')
export class Podcast extends Content {
@Column()
audioUrl: string;
@Column()
host: string;
}
// Usage
const article = new Article();
article.title = 'My Article';
article.body = 'Article content...';
await contentRepository.save(article);
const video = new Video();
video.title = 'My Video';
video.duration = 300;
video.url = 'https://example.com/video.mp4';
await contentRepository.save(video);
// Query all content
const allContent = await contentRepository.find();
// Query specific type
const articles = await contentRepository.find({
where: { type: 'article' },
});

Efficiently query hierarchical data using closure tables.

import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, Tree, TreeParent, TreeChildren, JoinColumn } from 'typeorm';
@Entity('categories')
@Tree('closure-table')
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
// Parent category
@TreeParent()
@ManyToOne(() => Category)
@JoinColumn({ name: 'parentId' })
parent: Category;
@Column({ nullable: true })
parentId: number;
// Child categories
@TreeChildren({ cascade: true })
@OneToMany(() => Category, category => category.parent)
children: Category[];
}
// Usage
const parent = new Category();
parent.name = 'Electronics';
await categoryRepository.save(parent);
const child1 = new Category();
child1.name = 'Phones';
child1.parent = parent;
await categoryRepository.save(child1);
const child2 = new Category();
child2.name = 'Laptops';
child2.parent = parent;
await categoryRepository.save(child2);
// Find tree
const trees = await categoryRepository.findTrees();
// Returns full tree structure
// Find ancestors
const ancestors = await categoryRepository.findAncestors(child1);
// Returns [Electronics, Phones]
// Find descendants
const descendants = await categoryRepository.findDescendants(parent);
// Returns [Electronics, Phones, Laptops]
// Count descendants
const count = await categoryRepository.countDescendants(parent);
// Returns 3

Another approach for hierarchical data using path strings.

import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, Tree, TreeParent, TreeChildren, JoinColumn } from 'typeorm';
@Entity('categories')
@Tree('materialized-path')
export class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ nullable: true })
path: string; // e.g., "1.2.3" for nested path
@TreeParent()
@ManyToOne(() => Category)
@JoinColumn({ name: 'parentId' })
parent: Category;
@TreeChildren({ cascade: true })
@OneToMany(() => Category, category => category.parent)
children: Category[];
}

Access entity metadata programmatically.

import { DataSource, getMetadataArgsStorage } from 'typeorm';
import { User } from './entities/user.entity';
// Get metadata from DataSource
const metadata = AppDataSource.getMetadata(User);
console.log(metadata.tableName); // 'users'
console.log.metadata.columns); // Array of column metadata
console.log(metadata.relations); // Array of relation metadata
console.log(metadata.primaryColumns); // Primary key columns
// Get metadata args storage
const storage = getMetadataArgsStorage();
console.log(storage.tables); // All table metadata
console.log(storage.columns); // All column metadata
console.log(storage.relations); // All relation metadata

Computed columns that don’t exist in the database.

import { Entity, PrimaryGeneratedColumn, Column, AfterLoad } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
// Virtual property (not a column)
fullName: string;
@AfterLoad()
updateFullName() {
this.fullName = `${this.firstName} ${this.lastName}`;
}
}
// Usage
const user = await userRepository.findOne({ where: { id: 1 } });
console.log(user.fullName); // 'John Doe'

Advanced Entity Features Summary
+------------------------------------------------------------------+
| |
| Feature | Purpose |
| ---------------------|----------------------------------------|
| Entity Listeners | Lifecycle hooks on entity |
| Subscribers | Lifecycle hooks (separate class) |
| Soft Delete | Mark as deleted without removing |
| Optimistic Locking | Prevent concurrent update conflicts |
| Lazy Relations | Load relations on demand |
| Transformers | Convert data between app and DB |
| Embedded Entities | Embed entity without separate table |
| Table Inheritance | Multiple types in one table |
| Tree Structures | Hierarchical data support |
| Virtual Columns | Computed properties |
| |
+------------------------------------------------------------------+

Chapter 11: Repository Pattern Deep Dive


Last Updated: February 2026