single table inheritance
Single Table Inheritance, or STI, stores several model types in one table. The parent table has a type column, and each child class maps to one enum value. Use STI when those types share one lifecycle and table, but need different behavior, validations, child-specific columns, serializers, or controller dispatch.
generating STI models
Generate the parent with --sti-base-serializer. The type column must be a database enum, and the enum values must exactly match the child class names:
pnpm psy g:resource --sti-base-serializer --owning-model=Place \
v1/host/places/\{\}/rooms Room type:enum:room_types:Bathroom,Bedroom,Kitchen,Den,LivingRoom \
Place:belongs_to position:integer:optional
Then generate each child with g:sti-child:
pnpm psy g:sti-child Room/Bedroom extends Room bed_types:enum[]:bed_types:king,queen,bunk
pnpm psy g:sti-child Room/Kitchen extends Room appliances:enum[]:appliance_types:stove,oven,microwave
STI children alter the parent table. They do not get their own tables, and child-specific columns are nullable at the database level because sibling rows do not use them.
model constraints
Keep shared associations and shared decorators on the parent model. STI children should not declare their own associations, @SoftDelete(), @Sortable(), or @ReplicaSafe() decorators. @SoftDelete() belongs on the parent and applies to every child.
export default class Room extends ApplicationModel {
public type: DreamColumn<Room, 'type'>
@deco.BelongsTo('Place')
public place: Place
public placeId: DreamColumn<Room, 'placeId'>
}
@STI(Room)
export default class Bedroom extends Room {
public bedTypes: DreamColumn<Bedroom, 'bedTypes'>
}
Queries against a child automatically include the STI default scope:
await Room.all()
// [Bathroom{}, Bedroom{}, Kitchen{}, ...]
await Bedroom.all()
// [Bedroom{}, Bedroom{}, ...]
controller creation
extractParams(Room, [...]) can extract child-specific columns, but it intentionally strips the STI type. Read type with castParam, validate it against the generated database enum values, and dispatch with an exhaustive switch:
const roomType = this.castParam('type', 'string', { enum: RoomTypesEnumValues })
switch (roomType) {
case 'Bathroom':
room = await this.place.createAssociation('rooms', Bathroom, this.extractParams(Room, ['bathOrShowerStyle']))
break
case 'Bedroom':
room = await this.place.createAssociation('rooms', Bedroom, this.extractParams(Room, ['bedTypes']))
break
case 'Kitchen':
room = await this.place.createAssociation('rooms', Kitchen, this.extractParams(Room, ['appliances']))
break
case 'Den':
case 'LivingRoom':
room = await this.place.createAssociation('rooms', roomType === 'Den' ? Den : LivingRoom, {})
break
default: {
const _never: never = roomType
throw new Error(`Unhandled room type: ${String(_never)}`)
}
}
Pair that controller with OpenAPI request-body narrowing:
@OpenAPI(Room, {
status: 201,
requestBody: { including: ['type'] },
})
serializers
The generated base serializer is intentionally generic so child serializers can preserve OpenAPI discrimination. When hand-adding a serializer variant, keep the StiChildClass parameter and render type as the single child enum value:
export const RoomForGuestsSerializer = (
StiChildClass: typeof Room | null,
room: Room,
passthrough: { locale: LocalesEnum },
) =>
DreamSerializer(StiChildClass ?? Room, room, passthrough)
.attribute('id')
.attribute('type', { openapi: { type: 'string', enum: [StiChildClass?.sanitizedName || room.type] } })
.delegatedAttribute('currentLocalizedText', 'title', { openapi: 'string' })
Dropping the generic child class shape can make every child serialize as the parent schema over HTTP, even if unit-level rendering looks correct.
STI and localized text
STI works with polymorphic localized content. Keep the polymorphic association on the STI parent, then let child serializers extend the parent serializer and add child-specific localized attributes.
@deco.HasMany('LocalizedText', { polymorphic: true, on: 'localizableId', dependent: 'destroy' })
public localizedTexts: LocalizedText[]
@deco.HasOne('LocalizedText', {
polymorphic: true,
on: 'localizableId',
and: { locale: DreamConst.passthrough },
})
public currentLocalizedText: LocalizedText