Table Per Class Inheritance (TPC)
1. What is Table Per Class Inheritance?
Table Per Class (also called Concrete Table Inheritance) maps each entity in the hierarchy to its own independent table that contains ALL columns -- both inherited and class-specific. Think of it like each family member living in a completely separate house, each with their own copy of the shared furniture. There is no shared space at all. No foreign keys link parent and child tables, no discriminator column is stored physically, and no JOINs are needed for single-entity queries.
2. The Schema
Each entity gets a fully self-contained table. The parent's columns (id, amount) are duplicated in every child table.
PostgreSQL DDL:
CREATE TABLE "payment" (
"id" SERIAL PRIMARY KEY,
"amount" INTEGER NOT NULL
);
CREATE TABLE "credit_card_payment" (
"id" SERIAL PRIMARY KEY,
"amount" INTEGER NOT NULL,
"cardNumber" VARCHAR(255) NOT NULL
);
CREATE TABLE "bank_transfer_payment" (
"id" SERIAL PRIMARY KEY,
"amount" INTEGER NOT NULL,
"bankCode" VARCHAR(255) NOT NULL
);MySQL DDL:
CREATE TABLE `payment` (
`id` INT NOT NULL AUTO_INCREMENT,
`amount` INT NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `credit_card_payment` (
`id` INT NOT NULL AUTO_INCREMENT,
`amount` INT NOT NULL,
`cardNumber` VARCHAR(255) NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `bank_transfer_payment` (
`id` INT NOT NULL AUTO_INCREMENT,
`amount` INT NOT NULL,
`bankCode` VARCHAR(255) NOT NULL,
PRIMARY KEY (`id`)
);Notice three things:
- There are no foreign keys between the three tables. They are completely independent.
- The columns
idandamountare duplicated in every table. Each table is self-sufficient. - There is no discriminator column physically stored. When querying polymorphically, the ORM synthesizes one on-the-fly using
UNION ALL.
3. Entity Definition
// payment.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
Inheritance,
DiscriminatorColumn,
} from "@stingerloom/orm";
@Entity()
@Inheritance({ strategy: "TABLE_PER_CLASS" })
@DiscriminatorColumn({ name: "payment_type", type: "varchar", length: 50 })
export class Payment {
@PrimaryGeneratedColumn()
id!: number;
@Column()
amount!: number;
}// credit-card-payment.entity.ts
import { Entity, Column, DiscriminatorValue } from "@stingerloom/orm";
import { Payment } from "./payment.entity";
@Entity()
@DiscriminatorValue("credit_card")
export class CreditCardPayment extends Payment {
@Column()
cardNumber!: string;
}// bank-transfer-payment.entity.ts
import { Entity, Column, DiscriminatorValue } from "@stingerloom/orm";
import { Payment } from "./payment.entity";
@Entity()
@DiscriminatorValue("bank_transfer")
export class BankTransferPayment extends Payment {
@Column()
bankCode!: string;
}Notice that each child class uses @Entity() without specifying a name -- the table name defaults to the snake_case of the class name (e.g., CreditCardPayment becomes credit_card_payment). Unlike STI, TPC children do not share the parent's table name. Each class lives in its own table.
The @DiscriminatorColumn decorator is still applied to the root entity, but for TPC the discriminator is not stored in the database. It is used only by the ORM to synthesize a virtual column during polymorphic UNION ALL queries.
4. INSERT -- Direct Insert
const cc = await em.save(CreditCardPayment, {
amount: 100,
cardNumber: "4111-1111-1111-1111",
});Generated SQL (PostgreSQL):
INSERT INTO "credit_card_payment" ("amount", "cardNumber")
VALUES (100, '4111-1111-1111-1111')
RETURNING *;Raw SQL result:
| id | amount | cardNumber |
|---|---|---|
| 1 | 100 | 4111-1111-1111-1111 |
Deserialized TypeScript object:
CreditCardPayment {
id: 1,
amount: 100,
cardNumber: "4111-1111-1111-1111"
}This is the simplest INSERT of all three strategies. There is no parent table to insert into (unlike TPT), no discriminator column to set (unlike STI). One row goes into one table. That is it.
A bank transfer works the same way -- single INSERT into bank_transfer_payment:
const bt = await em.save(BankTransferPayment, {
amount: 200,
bankCode: "SWIFT123",
});
// BankTransferPayment { id: 1, amount: 200, bankCode: "SWIFT123" }5. SELECT -- Querying a Child Entity
When you query a child entity, the ORM queries its own table directly. No JOINs, no subqueries, no union.
const cards = await em.find(CreditCardPayment, {});Generated SQL (PostgreSQL):
SELECT "id", "amount", "cardNumber"
FROM "credit_card_payment";Raw SQL result:
| id | amount | cardNumber |
|---|---|---|
| 1 | 100 | 4111-1111-1111-1111 |
Deserialized TypeScript object:
[
CreditCardPayment {
id: 1,
amount: 100,
cardNumber: "4111-1111-1111-1111"
}
]This is the fastest SELECT of all three strategies -- direct table access with no JOINs and no discriminator filtering. The database does not touch any other table.
6. SELECT -- Polymorphic Query (Root Entity) with UNION ALL
This is where TPC gets interesting. When you query the root entity Payment, the ORM must combine rows from all tables in the hierarchy. It does this with a UNION ALL query, padding missing columns with NULL.
const all = await em.find(Payment, {});Generated SQL (PostgreSQL):
SELECT * FROM (
SELECT "id", "amount", "cardNumber", NULL AS "bankCode",
'Payment' AS "payment_type"
FROM "payment"
UNION ALL
SELECT "id", "amount", "cardNumber", NULL AS "bankCode",
'credit_card' AS "payment_type"
FROM "credit_card_payment"
UNION ALL
SELECT "id", "amount", NULL AS "cardNumber", "bankCode",
'bank_transfer' AS "payment_type"
FROM "bank_transfer_payment"
) "_tpc";Notice four things:
- Each sub-SELECT lists every column from the entire hierarchy. If a table does not have a column,
NULLis used as a placeholder. - A virtual
"payment_type"column is synthesized using the@DiscriminatorValuestring. This column does not exist in any table -- it is created on-the-fly. - The entire UNION is wrapped in a subquery aliased as
"_tpc". - The root entity (
Payment) is included as a sub-SELECT too, withNULLfor child-specific columns.
Raw SQL result:
| id | amount | cardNumber | bankCode | payment_type |
|---|---|---|---|---|
| 1 | 50 | NULL | NULL | Payment |
| 1 | 100 | 4111-1111-1111-1111 | NULL | credit_card |
| 1 | 200 | NULL | SWIFT123 | bank_transfer |
Deserialized TypeScript object:
[
Payment { id: 1, amount: 50 },
CreditCardPayment { id: 1, amount: 100, cardNumber: "4111-1111-1111-1111" },
BankTransferPayment { id: 1, amount: 200, bankCode: "SWIFT123" }
]The ResultTransformer.toPolymorphicEntities() method reads the synthesized payment_type column, looks up the matching entity class from the discriminator map, and instantiates the correct subclass. Each object in the returned array is a proper instanceof its respective class.
Why UNION ALL Is Slow
Let's break down why this query can be expensive.
How UNION ALL works: UNION ALL concatenates the results of each SELECT statement. The database engine must scan each participating table independently. In the example above, it reads all three tables: payment, credit_card_payment, and bank_transfer_payment.
Why full scans happen: With STI or TPT, the database works with one table (or a pair of JOINed tables) and can use indexes to fetch only the rows it needs. But TPC polymorphic queries are different. When you run em.find(Payment, {}), the ORM cannot know in advance which child tables contain data, so it must scan every child table without exception. With 3 child types, that is 3 table scans. With 10 child types, 10 table scans. If each table has 1 million rows, a single polymorphic query reads millions of rows.
WHERE clauses don't fully help: Even with em.find(Payment, { where: { amount: 100 } }), the database applies the WHERE clause independently to each SELECT in the UNION ALL. It searches amount = 100 in all three tables separately, then combines the results. Indexes within each table help, but the overhead of repeating the search across N tables is unavoidable. By contrast, STI searches one table once and is done.
WARNING
Bottom line: TPC is best when em.find(ChildEntity, {}) (querying a specific child type) is the common case, and em.find(Payment, {}) (polymorphic queries across the whole hierarchy) is rare. Child-specific queries are the fastest of all three strategies (direct table, no JOINs), but polymorphic queries are the slowest. Understand this trade-off before choosing TPC.
7. SELECT -- With findOne
const card = await em.findOne(CreditCardPayment, {
where: { id: 1 },
});Generated SQL (PostgreSQL):
SELECT "id", "amount", "cardNumber"
FROM "credit_card_payment"
WHERE "id" = 1
LIMIT 1;Raw SQL result:
| id | amount | cardNumber |
|---|---|---|
| 1 | 100 | 4111-1111-1111-1111 |
Deserialized TypeScript object:
CreditCardPayment {
id: 1,
amount: 100,
cardNumber: "4111-1111-1111-1111"
}This is identical to a non-inheritance findOne. Since TPC children have their own table, no extra logic is needed.
8. SELECT -- With QueryBuilder
The SelectQueryBuilder fully supports TPC inheritance, including polymorphic queries.
Child entity query:
const cards = await em
.createQueryBuilder(CreditCardPayment, "cc")
.where("amount", 100)
.getMany();Generated SQL (PostgreSQL):
SELECT "cc".*
FROM "credit_card_payment" "cc"
WHERE "cc"."amount" = $1;Deserialized result:
[
CreditCardPayment {
id: 1,
amount: 100,
cardNumber: "4111-1111-1111-1111"
}
]Child queries work naturally -- the builder queries the child's own table directly with no extra logic.
Polymorphic root entity query:
const all = await em
.createQueryBuilder(Payment, "p")
.getMany();Generated SQL (PostgreSQL):
SELECT * FROM (
SELECT "id", "amount", "cardNumber", NULL AS "bankCode",
'Payment' AS "payment_type"
FROM "payment"
UNION ALL
SELECT "id", "amount", "cardNumber", NULL AS "bankCode",
'credit_card' AS "payment_type"
FROM "credit_card_payment"
UNION ALL
SELECT "id", "amount", NULL AS "cardNumber", "bankCode",
'bank_transfer' AS "payment_type"
FROM "bank_transfer_payment"
) "_tpc";Deserialized result:
[
Payment { id: 1, amount: 50 },
CreditCardPayment { id: 1, amount: 100, cardNumber: "4111-1111-1111-1111" },
BankTransferPayment { id: 1, amount: 200, bankCode: "SWIFT123" }
]
// Each element is the correct subclass instance -- instanceof checks work.The QueryBuilder automatically generates the UNION ALL subquery with NULL padding and a virtual discriminator column. The ResultTransformer.toPolymorphicEntities() method reads the synthesized discriminator and instantiates the correct subclass, just like em.find(). All QueryBuilder methods (getMany(), getOne(), getCount(), exists()) support TPC polymorphic deserialization.
WARNING
The same UNION ALL performance considerations apply to QueryBuilder polymorphic queries as to em.find(). See Section 6 for details on why this can be slow at scale.
9. SELECT -- With WriteBuffer
The WriteBuffer (Unit of Work plugin) delegates to EntityManager internally, so inheritance strategies work transparently. The identity map correctly tracks subclass instances.
import { bufferPlugin } from "@stingerloom/orm";
// Enable the buffer plugin
em.extend(bufferPlugin());
const buf = em.buffer();
// Find child entities -- queries credit_card_payment table
const cards = await buf.find(CreditCardPayment, {});
// cards are now tracked in the identity map
// Polymorphic find -- UNION ALL across all tables
const all = await buf.find(Payment, {});
// all contains correct subclass instances (CreditCardPayment, BankTransferPayment, etc.)
// Modify a tracked entity
cards[0].amount = 999;
// Flush changes -- UPDATE hits credit_card_payment table only
const result = await buf.flush();
// result.updates === 1Generated SQL on buf.flush() (PostgreSQL):
UPDATE "credit_card_payment"
SET "amount" = 999
WHERE "id" = 1;The dirty tracking detects which properties changed and flushes an UPDATE to the correct child table.
10. UPDATE -- Direct Update
const cc = await em.findOne(CreditCardPayment, {
where: { id: 1 },
});
cc.amount = 200;
cc.cardNumber = "9999-9999-9999-9999";
await em.save(CreditCardPayment, cc);Generated SQL (PostgreSQL):
UPDATE "credit_card_payment"
SET "amount" = 200, "cardNumber" = '9999-9999-9999-9999'
WHERE "id" = 1;A single UPDATE on the child's own table. Unlike TPT (which must update both the parent and child table), TPC only touches one table because all columns live there.
11. DELETE -- Direct Delete
await em.delete(CreditCardPayment, { id: 1 });Generated SQL (PostgreSQL):
DELETE FROM "credit_card_payment"
WHERE "id" = 1;A single DELETE from the child's own table. No cascade to a parent table (there is none to cascade to).
12. Pros and Cons
| Pros | Cons | |
|---|---|---|
| Child queries | Fastest SELECT -- direct table, no JOINs | -- |
| INSERT | Single INSERT, simplest of all strategies | -- |
| UPDATE | Single UPDATE, one table only | -- |
| DELETE | Single DELETE, one table only | -- |
| Schema | Each table is self-contained and easy to understand | Columns are duplicated across tables |
| Polymorphic query | -- | Requires UNION ALL (scans all tables, slow at scale) |
| Schema changes | -- | Adding a shared column requires ALTER TABLE on every child table |
| Data integrity | -- | No FK constraints between parent and child |
| ID uniqueness | -- | Auto-increment IDs can overlap across tables (see Section 14) |
13. When to Use TPC
Use TPC when each child type is queried independently and polymorphic queries are rare.
Good candidates: audit/log tables where each event type has very different fields, entities with high query volume where JOIN cost (TPT) or discriminator filtering (STI) is unacceptable, and hierarchies where shared fields are a small fraction of the total.
Bad candidates: systems that frequently need "give me all payments" (the UNION ALL cost adds up), and systems that enforce cross-type FK constraints (since tables are independent, you cannot create a FK that references "any payment").
14. ID Conflicts
WARNING
Since tables are independent, auto-increment IDs can overlap. CreditCardPayment with id=1 and BankTransferPayment with id=1 are different rows in different tables. When you mix results from a polymorphic query, two objects may share the same id value but represent completely different records.
If your application needs globally unique IDs across the hierarchy, use @PrimaryGeneratedColumn("uuid") instead of auto-increment:
@PrimaryGeneratedColumn("uuid")
id!: string;This generates UUIDv7 values that are unique across all tables, eliminating the overlap problem.
15. Next Steps
- Inheritance Mapping Overview -- Compare all three strategies (STI, TPT, TPC) side by side
- EntityManager -- find, save, delete, aggregation, pagination
- Query Builder -- Complex queries with JOIN, GROUP BY, subqueries
- Write Buffer -- Unit of Work pattern with identity map and dirty tracking
- Relations -- @ManyToOne, @OneToMany, @ManyToMany, @OneToOne