Advanced_entities
Chapter 10: Advanced Entity Patterns
Section titled “Chapter 10: Advanced Entity Patterns”Advanced Entity Features and Patterns
Section titled “Advanced Entity Features and Patterns”10.1 Entity Listeners & Subscribers
Section titled “10.1 Entity Listeners & Subscribers”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)
================================================================================Entity Listeners (Decorators)
Section titled “Entity Listeners (Decorators)”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`); }}Subscribers (Separate Class)
Section titled “Subscribers (Separate Class)”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.tsimport { UserSubscriber } from './subscribers/user.subscriber';
export const AppDataSource = new DataSource({ // ... other options subscribers: [UserSubscriber],});10.2 Soft Delete
Section titled “10.2 Soft Delete”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 | | | +-------------------+ | | | +------------------------------------------------------------------+Implementation
Section titled “Implementation”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 deleteawait userRepository.softDelete(1);// orawait userRepository.softRemove(user);
// Restore soft deleted entityawait userRepository.restore(1);// orawait userRepository.recover(user);
// Find including soft deletedconst allUsers = await userRepository.find({ withDeleted: true,});
// Find only soft deletedconst deletedUsers = await userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) },});10.3 Optimistic Locking
Section titled “10.3 Optimistic Locking”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 | | | +---------------------------+ | | | +------------------------------------------------------------------+Implementation
Section titled “Implementation”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;}
// Usageasync 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; }}10.4 Lazy Relations
Section titled “10.4 Lazy Relations”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;}
// Usageconst author = await authorRepository.findOne({ where: { id: 1 } });
// Books not loaded yetconsole.log(author.books); // Promise
// Load books when neededconst books = await author.books;console.log(books); // Array of books10.5 Entity Transformers
Section titled “10.5 Entity Transformers”Transform data between application and database formats.
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
// Custom transformer for JSONclass JsonTransformer { to(value: any): string { return JSON.stringify(value); } from(value: string): any { return JSON.parse(value); }}
// Custom transformer for encryptionclass 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 transformerclass 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;}10.6 Embedded Entities
Section titled “10.6 Embedded Entities”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)// );
// Usageconst 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',};10.7 Single Table Inheritance
Section titled “10.7 Single Table Inheritance”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;}
// Usageconst 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 contentconst allContent = await contentRepository.find();
// Query specific typeconst articles = await contentRepository.find({ where: { type: 'article' },});10.8 Closure Table for Trees
Section titled “10.8 Closure Table for Trees”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[];}
// Usageconst 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 treeconst trees = await categoryRepository.findTrees();// Returns full tree structure
// Find ancestorsconst ancestors = await categoryRepository.findAncestors(child1);// Returns [Electronics, Phones]
// Find descendantsconst descendants = await categoryRepository.findDescendants(parent);// Returns [Electronics, Phones, Laptops]
// Count descendantsconst count = await categoryRepository.countDescendants(parent);// Returns 310.9 Materialized Path for Trees
Section titled “10.9 Materialized Path for Trees”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[];}10.10 Entity Metadata Access
Section titled “10.10 Entity Metadata Access”Access entity metadata programmatically.
import { DataSource, getMetadataArgsStorage } from 'typeorm';import { User } from './entities/user.entity';
// Get metadata from DataSourceconst metadata = AppDataSource.getMetadata(User);
console.log(metadata.tableName); // 'users'console.log.metadata.columns); // Array of column metadataconsole.log(metadata.relations); // Array of relation metadataconsole.log(metadata.primaryColumns); // Primary key columns
// Get metadata args storageconst storage = getMetadataArgsStorage();
console.log(storage.tables); // All table metadataconsole.log(storage.columns); // All column metadataconsole.log(storage.relations); // All relation metadata10.11 Virtual Columns
Section titled “10.11 Virtual Columns”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}`; }}
// Usageconst user = await userRepository.findOne({ where: { id: 1 } });console.log(user.fullName); // 'John Doe'10.12 Summary
Section titled “10.12 Summary” 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 | | | +------------------------------------------------------------------+Next Chapter
Section titled “Next Chapter”Chapter 11: Repository Pattern Deep Dive
Last Updated: February 2026