How to Implement Polymorphic Relations in MikroORM 7
Problem
I was building a content management system where users can comment on posts, videos, and photos. Each content type needed its own table, but comments should work across all of them.
My first attempt looked like this:
@Entity()export class Comment { @PrimaryKey() id!: number;
@Property() content!: string;
// Ugly: multiple nullable foreign keys @ManyToOne(() => Post) post?: Post;
@ManyToOne(() => Video) video?: Video;
@ManyToOne(() => Photo) photo?: Photo;}This created a sparse table with mostly null columns. Queries became messy:
// Had to check each relationshipconst comments = await em.find(Comment, { $or: [ { post: { id: postId } }, { video: { id: videoId } }, { photo: { id: photoId } } ]});Type safety was weak. I could accidentally set both post and video on the same comment.
Environment
- MikroORM 7.x
- TypeScript 5.x
- PostgreSQL 15
The Old Workarounds
Before MikroORM 7, I tried several approaches. None were satisfactory.
Approach 1: Discriminator Strings
@Entity()export class Comment { @Property() parentType!: string; // 'post' | 'video' | 'photo'
@Property() parentId!: number;
// No foreign key constraint // No type safety // Manual joins required}This sacrificed database integrity. No foreign keys meant no cascade deletes. I had to manually clean up orphaned comments.
Approach 2: Join Tables
// Separate join table for each content type@Entity()export class PostComment { @ManyToOne(() => Post) post!: Post;
@ManyToOne(() => Comment) comment!: Comment;}
@Entity()export class VideoComment { @ManyToOne(() => Video) video!: Video;
@ManyToOne(() => Comment) comment!: Comment;}This exploded the number of tables. Each new content type required a new join table. Queries became complex unions.
The Solution: MikroORM 7 Polymorphic Relations
MikroORM 7 introduced native polymorphic relations. This was one of the most requested features in the ORMβs history.
Step 1: Define a Common Interface
First, I created an interface that all commentable entities implement:
import { Collection } from '@mikro-orm/core';import { Comment } from './comment.entity';
export interface Commentable { id: number; comments: Collection<Comment>;}Step 2: Create Entity Types
Each content type implements the interface:
@Entity()export class Post implements Commentable { @PrimaryKey() id!: number;
@Property() title!: string;
@Property() content!: string;
@OneToMany(() => Comment, comment => comment.parent) comments = new Collection<Comment>(this);}@Entity()export class Video implements Commentable { @PrimaryKey() id!: number;
@Property() title!: string;
@Property() duration!: number;
@OneToMany(() => Comment, comment => comment.parent) comments = new Collection<Comment>(this);}@Entity()export class Photo implements Commentable { @PrimaryKey() id!: number;
@Property() title!: string;
@Property() url!: string;
@OneToMany(() => Comment, comment => comment.parent) comments = new Collection<Comment>(this);}Step 3: Define the Polymorphic Relation
The magic happens in the Comment entity:
@Entity()export class Comment { @PrimaryKey() id!: number;
@Property() content!: string;
@Property() createdAt = new Date();
// Polymorphic relation - can reference Post, Video, or Photo @ManyToOne(() => [Post, Video, Photo]) parent!: Post | Video | Photo;}The @ManyToOne(() => [Post, Video, Photo]) syntax tells MikroORM that this relation can point to any of these entity types.
How It Works Under the Hood
MikroORM creates a discriminator column to track the entity type:
CREATE TABLE comment ( id SERIAL PRIMARY KEY, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT NOW(), parent_id INTEGER NOT NULL, parent_type VARCHAR(255) NOT NULL -- Discriminator column);When I query comments, MikroORM automatically joins the correct parent table based on the discriminator.
Querying Polymorphic Relations
Find Comments by Parent
// Find all comments on a specific postconst postComments = await em.find(Comment, { parent: { id: postId }});
// Works for any parent typeconst videoComments = await em.find(Comment, { parent: { id: videoId }});Load Comments with Parent
const comments = await em.find(Comment, {}, { populate: ['parent']});
// Type narrowing for safetyfor (const comment of comments) { if (comment.parent instanceof Post) { console.log(`Comment on post: ${comment.parent.title}`); } else if (comment.parent instanceof Video) { console.log(`Comment on video: ${comment.parent.title}`); } else if (comment.parent instanceof Photo) { console.log(`Comment on photo: ${comment.parent.title}`); }}Query Builder Integration
const comments = await em.createQueryBuilder(Comment, 'c') .leftJoinAndSelect('c.parent', 'p') .where({ 'p.title': { $like: '%important%' } }) .getResult();Table-Per-Type Inheritance Alternative
For scenarios where entities share common fields, MikroORM 7 also supports Table-Per-Type inheritance:
@Entity({ discriminatorColumn: 'type', discriminatorMap: { post: 'Post', video: 'Video', photo: 'Photo', },})export abstract class Media { @PrimaryKey() id!: number;
@Property() title!: string;
@Property() createdAt = new Date();}@Entity({ discriminatorValue: 'post' })export class Post extends Media { @Property() content!: string;
@OneToMany(() => Comment, comment => comment.parent) comments = new Collection<Comment>(this);}This approach creates a base table for shared columns and separate tables for type-specific columns.
Common Mistakes I Made
Mistake 1: Forgetting Type Narrowing
// WRONG: TypeScript doesn't know the specific typeconst title = comment.parent.title; // Error if Photo has no title
// CORRECT: Narrow the type firstif (comment.parent instanceof Post) { const title = comment.parent.title; // TypeScript knows it's a Post}Mistake 2: Mixing Inheritance Strategies
I initially tried mixing Single Table Inheritance with polymorphic relations. This caused confusing errors. Stick to one strategy per entity hierarchy.
Mistake 3: Over-Engineering
Not every relationship needs polymorphism. I added it to a simple User -> Profile relationship where a regular @OneToOne would have been cleaner.
When to Use Polymorphic Relations
I use polymorphic relations when:
- Multiple entity types share the same relationship pattern (comments, tags, likes)
- The number of entity types might grow (adding new content types)
- I need type-safe queries across different parent types
- Foreign key integrity matters
I avoid polymorphic relations when:
- Only one or two entity types are involved
- The relationship is unlikely to change
- Simplicity outweighs flexibility
Performance Considerations
Polymorphic queries can be slower than direct relationships. MikroORM optimizes with:
- Batch loading to avoid N+1 queries
- Smart JOIN strategies based on discriminator
- Eager loading support for populated relations
I always index the discriminator column:
CREATE INDEX idx_comment_parent ON comment(parent_type, parent_id);Summary
MikroORM 7βs polymorphic relations solved a problem that had plagued my codebase for months. The old workarounds with nullable foreign keys and discriminator strings are gone.
Key takeaways:
- Use
@ManyToOne(() => [Entity1, Entity2, ...])for polymorphic relations - Implement a common interface for type safety
- Always narrow types when accessing polymorphic properties
- Index discriminator columns for performance
- Donβt over-engineer simple relationships
The feature was worth the wait. My comment system is now clean, type-safe, and maintainable.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- π¨βπ» MikroORM 7 Release Notes
- π¨βπ» MikroORM Polymorphic Relations Documentation
- π¨βπ» Table-Per-Type Inheritance
Oh, and if you found these resources useful, donβt forget to support me by starring the repo on GitHub!
Comments