TypeORM relation decorators — @OneToOne, @OneToMany/@ManyToOne, and @ManyToMany — declare how two entities relate, which side owns the foreign key (or join table), and how TypeORM should load the related data.
In simpler words
These decorators are the TypeScript way of saying “this row can relate to exactly one other row,” “to many other rows,” or “to many other rows that also relate back to many of this one.”
This repo has a real 1:N relationship (User ↔ Ticket) — 1:1 and N:N are covered here as patterns you will need for other features, shown as illustrative extensions of this same schema.
Explain the real @ManyToOne / @OneToMany pair between Ticket and User
Sketch a @OneToOne relation with @JoinColumn
Sketch a @ManyToMany relation and know when it needs its own join entity
1:N in this repo: User ↔ Ticket
Definition
A one-to-many relationship pairs @ManyToOne on the “many” side (which owns the foreign key column via @JoinColumn) with @OneToMany on the “one” side (which is a computed inverse view with no column of its own).
In simpler words
One user can be the assignee of many tickets, but one ticket has at most one assignee — that asymmetry is exactly @ManyToOne / @OneToMany.
Ticket.assignee is @ManyToOne(() => User, ...) with @JoinColumn({ name: 'assignee_id' }) — this side owns the actual foreign key column.
User.tickets is @OneToMany('Ticket', 'assignee') — it has no column; TypeORM derives it by querying tickets where assignee_id matches.
The real pair
// ticket.entity.ts — owns the FK column
@ManyToOne(() => User, (user) => user.tickets, {
nullable: true,
onDelete: 'SET NULL',
})
@JoinColumn({ name: 'assignee_id' })
assignee?: User | null;
// user.entity.ts — inverse side, no column
@OneToMany('Ticket', 'assignee')
tickets?: Ticket[];
@JoinColumn only ever goes on the @ManyToOne (owning) side — never on @OneToMany.
1:1 pattern (illustrative)
Definition
A one-to-one relationship pairs @OneToOne on both sides, with exactly one side additionally carrying @JoinColumn to hold the actual foreign key — the other side is the inverse and has no column.
In simpler words
This repo does not have a 1:1 relation yet; a UserProfile split out from User is a realistic future example.
Only one side owns @JoinColumn — deciding which side "owns" the relation is a modeling choice, usually the side that cannot exist without the other.
A 1:1 with a unique foreign key column is how you would enforce “at most one profile per user” at the database level.
user_id would also need a UNIQUE constraint to truly enforce “one profile per user.”
N:N pattern (illustrative)
Definition
@ManyToMany declares a relation where many rows on each side can relate to many rows on the other, and TypeORM manages it through an automatically generated join table unless you model that join table as its own entity.
In simpler words
This repo does not have an N:N relation yet; tagging tickets with reusable labels (Ticket ↔ Tag) is a realistic future example.
A plain @ManyToMany + @JoinTable() lets TypeORM manage the join table for you, fine when the relation itself carries no extra data.
If the relationship needs its own columns (who added the tag, when), model the join table as its own entity with two @ManyToOne relations instead — this is the more common real-world shape.
@JoinTable() belongs on exactly one side — it tells TypeORM which side owns/manages the join table.
Owning side, cascades, and eager loading traps
Definition
The owning side of a relation is the side that owns the foreign key or join table; cascade options tell TypeORM to propagate save/remove operations across the relation; eager loading always joins related rows on every find.
In simpler words
Wrong cascade or eager settings create silent deletes and unexpected N+1-style cost even when you think you are being careful.
Put @JoinColumn / @JoinTable on exactly one side — the owning side. Inverse sides are derived views.
cascade: true on remove can delete more than you intend (for example removing a user and wiping tickets). Prefer explicit service logic for destructive cascades.
eager: true means every find loads the relation whether you need it or not — fine for tiny always-needed data, dangerous for large graphs. Prefer leftJoinAndSelect when the endpoint needs the relation.
lazy relations (Promise wrappers) can hide query timing until you await them in a loop — that recreates N+1.
Mistake: cascade remove without thinking
// Dangerous — deleting a user may wipe related tickets depending on cascade/onDelete
@OneToMany(() => Ticket, (t) => t.assignee, { cascade: true })
tickets?: Ticket[];
// Safer — explicit service decision + DB onDelete policy you reviewed in a migration
@ManyToOne(() => User, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'assignee_id' })
assignee?: User | null;
onDelete in the migration/FK and cascade in TypeORM are related but not identical — review both.
Mistake: inverse side owns the FK
// Wrong — @JoinColumn on @OneToMany
@OneToMany(() => Ticket, (t) => t.assignee)
@JoinColumn()
tickets?: Ticket[];
// Right — @JoinColumn on @ManyToOne owning side
@ManyToOne(() => User, (u) => u.tickets)
@JoinColumn({ name: 'assignee_id' })
assignee?: User | null;
Only the owning side stores the foreign key column.
Keep in mind
@JoinColumn (1:1, N:1) and @JoinTable (N:N) each go on exactly one side — the owning side.
The real relation to study first is Ticket.assignee / User.tickets — everything else here is a pattern, not code in this repo yet.
Reach for a join-table entity instead of plain @ManyToMany as soon as the relation itself needs extra columns.
Treat cascade and eager as deliberate production decisions, not defaults to sprinkle everywhere.
Test
Check your understanding
At least 10 questions — mix of concept, syntax, practical, and logic. Score ≥ 80% (enforced by the API) to save progress.