TypeORM Relations
Real applications are graphs, not isolated tables: a user has many posts, a post belongs to one author, and posts wear many tags. TypeORM models these connections with relation decorators that map directly to foreign keys and join tables, letting you traverse data as typed object references instead of hand-written joins. Getting relations right — which side owns the foreign key, how rows are loaded, and what cascades on save — is the difference between clean repositories and a layer riddled with N+1 queries.
Relation types at a glance
TypeORM supports the four classic cardinalities. Each is declared with a decorator on both sides of the association, and the relation is resolved by the inverse-side callback that points back at the partner entity.
| Relation | Owning-side decorator | Inverse-side decorator | Foreign key lives on |
|---|---|---|---|
| One-to-one | @OneToOne + @JoinColumn | @OneToOne | The side with @JoinColumn |
| Many-to-one | @ManyToOne | @OneToMany | The “many” side (always) |
| One-to-many | @OneToMany | @ManyToOne | The “many” side (always) |
| Many-to-many | @ManyToMany + @JoinTable | @ManyToMany | A separate junction table |
One-to-many and many-to-one
This is the most common pairing. A Post has exactly one author, and a User has many posts. The foreign key always sits on the “many” side — here post.authorId — which is why @ManyToOne is the owning side and @OneToMany is purely inverse.
// src/users/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { Post } from '../posts/post.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
}
// src/posts/post.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { User } from '../users/user.entity';
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@ManyToOne(() => User, (user) => user.posts, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author: User;
}
@JoinColumn lets you name the foreign-key column explicitly; without it TypeORM derives one like authorId. The onDelete: 'CASCADE' option is a database-level rule that removes a user’s posts when the user row is deleted.
One-to-one
Use one-to-one for a record that extends another with optional or sensitive data — a Profile for a User. Exactly one side carries @JoinColumn, and that side owns the foreign key.
import { Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'typeorm';
import { User } from './user.entity';
@Entity('profiles')
export class Profile {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'text', nullable: true })
bio: string | null;
@OneToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn()
user: User;
}
Many-to-many
Posts and tags relate many-to-many, which requires a junction table. The owning side declares @JoinTable; TypeORM then creates and maintains the join table automatically.
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from 'typeorm';
import { Tag } from './tag.entity';
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@ManyToMany(() => Tag, (tag) => tag.posts, { cascade: true })
@JoinTable({ name: 'post_tags' })
tags: Tag[];
}
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from 'typeorm';
import { Post } from './post.entity';
@Entity('tags')
export class Tag {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
name: string;
@ManyToMany(() => Post, (post) => post.tags)
posts: Post[];
}
Cascade options
Cascades control what TypeORM does to related entities when you save or remove a parent. They operate at the ORM level (distinct from the database-level onDelete). Enable them per relation with the cascade option.
| Value | Effect |
|---|---|
true | All cascade operations are enabled |
['insert'] | New related entities are inserted with the parent |
['update'] | Changed related entities are updated with the parent |
['remove'] | Related entities are removed with the parent |
['soft-remove'] | Soft-delete propagates to related entities |
With cascade: true you can persist a parent and its children in a single save:
const post = postRepo.create({
title: 'TypeORM relations',
tags: [tagRepo.create({ name: 'orm' }), tagRepo.create({ name: 'nestjs' })],
});
await postRepo.save(post); // post + both tags + join rows in one call
Avoid
cascade: trueon relations whose children are shared across many parents (like tags). A stray edit can rewrite rows you didn’t intend to touch. Prefer narrow arrays such as['insert']and manage updates explicitly.
Eager vs lazy loading
By default a relation is not loaded — you must ask for it. You can change the default per relation:
- Eager (
eager: true): the relation loads automatically on everyfindcall. Convenient, but it fires extra joins everywhere and can balloon query cost. Eager relations are ignored by QueryBuilder. - Lazy (typed as
Promise<T>): the relation loads on first access via anawait. It adds a query per access, so it is easy to trigger N+1 problems in loops.
@ManyToOne(() => User, (user) => user.posts, { eager: true })
author: User; // always populated by find()
@OneToMany(() => Post, (post) => post.author, { lazy: true })
posts: Promise<Post[]>; // await postsPromise to load
Loading relations explicitly
The most predictable approach is to leave relations non-eager and request them per query with the relations option. It accepts an array of paths or a nested object for deep loading.
// Array form
const posts = await postRepo.find({
relations: ['author', 'tags'],
});
// Object form supports nesting
const post = await postRepo.findOne({
where: { id },
relations: { author: true, tags: true },
});
Output:
query: SELECT "Post"."id", "Post"."title", "author"."id", "author"."name"
FROM "posts" "Post"
LEFT JOIN "users" "author" ON "author"."id" = "Post"."author_id"
LEFT JOIN "post_tags" "Post_tags" ON "Post_tags"."postId" = "Post"."id"
LEFT JOIN "tags" "tags" ON "tags"."id" = "Post_tags"."tagId"
For finer control — filtering or paginating related rows — reach for QueryBuilder.leftJoinAndSelect instead of the relations option.
Best practices
- Remember the foreign key always lives on the
@ManyToOneside; make that side the owner and use@OneToManyonly as the inverse. - Prefer explicit per-query
relationsovereager: trueso each endpoint loads exactly the data it needs. - Reserve
eagerfor tiny, always-needed associations; never on collections that grow without bound. - Use narrow
cascadearrays (['insert']) rather thancascade: true, especially for shared entities like tags. - Combine ORM
cascadewith databaseonDelete: 'CASCADE'deliberately — they solve different layers of the same problem. - Watch for N+1 queries when iterating lazy relations; batch with
relationsor a singleleftJoinAndSelectinstead. - Name join columns and join tables (
@JoinColumn({ name }),@JoinTable({ name })) so migrations stay stable.