Entities and Relations
How one-to-many and many-to-many map to real columns and junction tables, and the SQL each loading style emits.
@ManyToOne(() => Customer, (customer) => customer.orders) customer: Customer;An entity is a class mapped to a table: @Entity() on the class, @Column() on each persisted field. Relation decorators describe links between tables, and only one side of a link gets a column.
Which side owns the column
@ManyToOne is the owning side of a one-to-many: the foreign key column is created on that table. @OneToMany adds nothing to its own table, which is why TypeORM states that @OneToMany cannot exist without a matching @ManyToOne. For many-to-many, @JoinTable() goes on exactly one side and creates the junction table, named after the owning table, the property and the target table: Order.tags pointing at Tag produces order_tags_tag.
The queries each shape produces
Nothing is loaded unless you ask. find({ relations: { items: true } }) adds a LEFT JOIN, and relations can nest: { items: { product: true } }. A @ManyToOne marked nullable: false is joined with INNER JOIN instead, since the row must exist.
Shorthand and longhand
These two write the same junction row, and the documented long form loads the whole collection first:
await dataSource.createQueryBuilder()
.relation(Order, 'tags').of(orderId).add(tagId);
// longhand
order.tags.push(tag);
await repo.save(order);What trips people up
The first argument is a thunk, () => Tag, not Tag. It defers evaluation so two entity files can import each other without one being undefined at decoration time.
Example
// src/orders/order.entity.ts
import {
Column,
CreateDateColumn,
Entity,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Customer } from '../customers/customer.entity.js';
import { OrderItem } from './order-item.entity.js';
import { Tag } from './tag.entity.js';
Entity()
export class Order {
PrimaryGeneratedColumn()
id: number;
Column({ type: 'varchar', length: 16, default: 'draft' })
status: string;
CreateDateColumn()
createdAt: Date;
// Owning side: the customerId foreign key column is created on "order".
// nullable: false also makes TypeORM join this relation with INNER JOIN.
ManyToOne(() => Customer, (customer) => customer.orders, { nullable: false })
customer: Customer;
// A column named exactly like the generated FK, so you can read and write
// the id without loading the Customer entity.
Column()
customerId: number;
// Collection side: no column is added to "order". cascade: ['insert'] lets
// save(order) insert new items in the same operation.
OneToMany(() => OrderItem, (item) => item.order, { cascade: ['insert'] })
items: OrderItem[];
// Owning side of the many-to-many: @JoinTable creates order_tags_tag.
ManyToMany(() => Tag, (tag) => tag.orders)
JoinTable()
tags: Tag[];
}When to use it
- Modelling an order that has many line items and many tags, and knowing which table gains a column.
- Mapping entities onto a legacy database whose columns are snake_case.
- Adding a tag to an order without loading its existing tag list.
- Deciding between one joined query and several smaller ones for a list endpoint.
More examples
The inverse sides, and the tables they produce
The three files that complete the relations above. Read them together to see that order holds customerId, order_item holds orderId, and only order_tags_tag holds both sides of the many-to-many. This is the layout a migration will generate, so it is worth agreeing on before the first migration is written.
// src/customers/customer.entity.ts
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Order } from '../orders/order.entity.js';
@Entity()
export class Customer {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
// Inverse side. Adds no column to "customer".
@OneToMany(() => Order, (order) => order.customer)
orders: Order[];
}
// src/orders/order-item.entity.ts
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Order } from './order.entity.js';
@Entity()
export class OrderItem {
@PrimaryGeneratedColumn()
id: number;
@Column()
sku: string;
@Column('int')
quantity: number;
// onDelete is a database-level rule written into the foreign key,
// not something TypeORM does in JavaScript.
@ManyToOne(() => Order, (order) => order.items, {
nullable: false,
onDelete: 'CASCADE',
})
order: Order;
@Column()
orderId: number;
}
// src/orders/tag.entity.ts
import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Order } from './order.entity.js';
@Entity()
export class Tag {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
name: string;
// No @JoinTable here: the owning side already declared it.
@ManyToMany(() => Order, (order) => order.tags)
orders: Order[];
}Naming the foreign key yourself
@JoinColumn and @JoinTable are optional on the owning side; omit them and TypeORM picks names. Spell them out when you are mapping onto a schema you did not design, or when a reviewer needs to see the column names in the entity rather than infer them. @JoinColumn is never needed on a @OneToMany.
// src/orders/order.entity.ts (excerpt)
import { Entity, JoinColumn, ManyToOne } from 'typeorm';
import { Customer } from '../customers/customer.entity.js';
@Entity()
export class Order {
// Shorthand: TypeORM derives the column name "customerId" from the property.
@ManyToOne(() => Customer, (customer) => customer.orders)
customer: Customer;
}
// Longhand: the same column, spelled out. Use it when the database already
// exists and its naming is snake_case.
@Entity({ name: 'orders' })
export class LegacyOrder {
@ManyToOne(() => Customer, (customer) => customer.orders)
@JoinColumn({ name: 'customer_id', referencedColumnName: 'id' })
customer: Customer;
}
// Many-to-many, longhand junction table:
// @ManyToMany(() => Tag, (tag) => tag.orders)
// @JoinTable({
// name: 'order_tag',
// joinColumn: { name: 'order_id', referencedColumnName: 'id' },
// inverseJoinColumn: { name: 'tag_id', referencedColumnName: 'id' },
// })
// tags: Tag[];Loading relations three ways
Same data, three query plans. Use find options by default, relationLoadStrategy: 'query' when two collections on one row multiply the joined rows, and QueryBuilder when a filter belongs in the JOIN condition rather than the WHERE clause. Note that a condition placed in leftJoinAndSelect filters the items; placed in .where it filters the orders.
// src/orders/orders.read.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from './order.entity.js';
@Injectable()
export class OrdersReadService {
constructor(
@InjectRepository(Order) private readonly orders: Repository<Order>,
) {}
// 1. find options: one statement with LEFT JOINs, nesting allowed.
withItemsAndCustomer(customerId: number) {
return this.orders.find({
where: { customerId },
relations: { items: true, customer: true },
take: 20,
});
}
// 2. the "query" strategy: the main row set, then one extra SELECT per
// relation instead of one wide join.
withItemsSeparately(customerId: number) {
return this.orders.find({
where: { customerId },
relations: { items: true, tags: true },
relationLoadStrategy: 'query',
take: 20,
});
}
// 3. QueryBuilder, when you need a condition on the joined table.
withUnshippedItems(customerId: number) {
return this.orders
.createQueryBuilder('order')
.leftJoinAndSelect('order.items', 'item', 'item.shippedAt IS NULL')
.where('order.customerId = :customerId', { customerId })
.orderBy('order.createdAt', 'DESC')
.take(20) // take/skip, not limit/offset, when joins are involved
.getMany();
}
}
Discussion