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
Generate the parent first: generation order is migration order. When a child declares columns, g:sti-child writes a timestamp-named migration that ALTERs the parent's table, so a child generated ahead of its parent sorts ahead of the CREATE TABLE it depends on and the run fails.
STI children alter the parent table rather than creating their own. A child with no additional columns gets its model, serializer, factory, and spec but no migration, since there is no schema change to make, so a batch of such children is fast to run. For each child column that is not :optional, a boolean, or an array, g:sti-child also generates a check constraint that requires a value only on the rows of that child type:
pnpm psy g:sti-child Room/Den extends Room
pnpm psy g:sti-child Room/LivingRoom extends Room
Reach for the generator even when a child looks trivial, since it handles child-column enum types and keeps the check constraints, factories, and serializers in sync for you.
STI children never receive @SoftDelete(). Soft delete is enforced at the STI parent level, and g:sti-child does not accept a --no-soft-delete flag.
naming the base: two STI shapes
Which shape you have decides what the base is called and where it lives.
- A general category with kinds under it.
Roomis never instantiated;Bedroom,BathroomandKitchenare. The base is the concept, so it takes the concept's name and path:Room.tsholdingRoom, children atRoom/Bedroom.ts. - Peers at the same level.
PlaceandDraftPlaceare both kinds of place, and neither is a general category. The base is a technical artifact holding what they share, so it lives inside the namespace:Place/Base.tsholdingBasePlace, with the concrete peers atPlace.tsandPlace/Draft.ts.
The peers shape is not an exception to the path-equals-class-name convention: the base there is not a domain concept, so naming it after one would assert a category that does not exist. That shape is also what keeps a retrofit cheap — the concrete class stays at its original path under its original global name, so every existing association and query still means what it meant. Choosing the first shape for a retrofit instead makes the existing model the base, and a base query returns every child, so the new variant leaks into every existing read.
shared columns across multiple children
When two or more children share a column name (say, Bedroom and LivingRoom both need capacity), include the column in each g:sti-child command so every child's model and serializer generate correctly. Because every child's migration ALTERs the same parent table, only the first migration should actually add the column. After generating, remove from each later migration the duplicate addColumn (and its matching dropColumn in down), which fails with "column already exists", and the duplicate rooms_not_null_capacity check constraint, which fails the same way.
If the column isn't :optional, a boolean, or an array, the first migration's check constraint (type != 'Bedroom' OR capacity IS NOT NULL) covers only its own child. Widen it to cover every child that requires the column:
// Not yet deployed: edit the first child's migration directly
sql`type NOT IN ('Bedroom', 'LivingRoom') OR capacity IS NOT NULL`
If the first migration has already shipped, generate a new migration that drops and replaces the constraint instead of editing the merged one.
Columns shared by all children belong on the STI base model instead, declared as part of the initial g:resource or g:model command.
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.
STI is exactly one level deep
@STI() always names the base model, even when the TypeScript extends chain runs deeper than one level. If Bunkroom extends Bedroom extends Room, Bunkroom must still be decorated @STI(Room), not @STI(Bedroom):
// Wrong: @STI(Bedroom) compiles and imports without error, then fails silently
@STI(Bedroom)
export default class Bunkroom extends Bedroom {}
// Right: always name the STI base, regardless of TS inheritance depth
@STI(Room)
export default class Bunkroom extends Bedroom {}
Decorating against the intermediate class instead of the base has no compile-time signal. At runtime, Bedroom.all() only matches rows whose type column is exactly 'Bedroom', so Bunkroom rows never join the base model's child list — they become invisible to preloadFor and absent from the generated OpenAPI schema.
// models/Room.ts
export default class Room extends ApplicationModel {
public type: DreamColumn<Room, 'type'>
@deco.BelongsTo('Place')
public place: Place
public placeId: DreamColumn<Room, 'placeId'>
}
// models/Room/Bedroom.ts
@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{}, ...]
virtual attributes don't filter up to the base class
Child-specific physical columns and child-specific virtual attributes behave differently when accessed through the STI base class. Physical columns live on the one shared table, so the base class sees all of them — Room.columns() returns them, and a base-class extractParams(Room, [...]) or @OpenAPI(Room, { requestBody: { params } }) call can name any child's column.
A @deco.Virtual attribute declared on a child, by contrast, is registered on that child class only, and inherits base → child, never child → base. Naming a child's virtual attribute through the base class type-checks — the generated schema aggregates every child's virtual columns under the shared table, so the type sees it — but is silently dropped at runtime, because the runtime filters the allowlist against the base class's own param-safe set, which excludes child-declared virtuals. The same gap hits @OpenAPI(Room, { requestBody: { params: ['childVirtual'] } }): it type-checks but renders nothing into the spec.
Handle a child's virtual attribute on the child class, not the base. In the create switch, pull it inside that child's case — extractParams(Bedroom, ['childVirtual']) resolves because Bedroom owns the virtual — and document it on a child-specific @OpenAPI(Bedroom, ...) if it needs to appear in the spec. Reserve the single base-class extractParams(Room, [...]) call for physical columns, which it covers completely.
targeting an STI child
Children cannot declare new associations — every association must live on the parent — but another model's association can point at one subtype. Pointing at a subtype does not define a new association on the child.
When a single child is the target, name it by its namespaced global name. No type clause is written anywhere, and the association property is typed as the child, so child-specific columns are reachable without a cast:
export default class Place extends ApplicationModel {
@deco.HasMany('Room')
public rooms: Room[]
// Loads only bedrooms, and `bedrooms` is typed as Bedroom[]
@deco.HasMany('Room/Bedroom')
public bedrooms: Bedroom[]
}
For several child types at once, scope the parent-declared association with an and clause on the type column, whose values are bare class names. This types the property as the base, so each child-specific access costs a cast:
export default class Place extends ApplicationModel {
// Loads several child types; `sleepingRooms` is typed as Room[]
@deco.HasMany('Room', { and: { type: ['Bedroom', 'Bathroom'] } })
public sleepingRooms: Room[]
}
Either way, loaded records come back instantiated as their child class (Dream reads the type column) and render through that child's serializer.
Across a through chain, an outer child may narrow an inner base: on Host, @deco.HasMany('Room/Bedroom', { through: 'places', source: 'rooms' }) returns only bedrooms even though Place.rooms targets Room. An outer base may not broaden an inner child, and sibling targets are incompatible. Both fail at query time — TypeScript accepts the declaration and nothing fires at decoration time — so an invalid chain surfaces later than you expect.
changing an STI record's type
Dream allows changing an STI record's type after creation by updating the type column. Set every field the new type requires in the same update — the database check constraints generated by g:sti-child reject invalid combinations regardless:
// Change a Bedroom into a Bathroom (bathOrShowerStyle is required on Bathroom)
await bedroom.update({ type: 'Bathroom', bathOrShowerStyle: 'shower' })
// Re-query via the base class — returns a Bathroom, but the type system doesn't know it
const room = await Room.findOrFail(bedroom.id)
// Re-query via the target child class instead — the type system knows it is a Bathroom
const bathroom = await Bathroom.findOrFail(bedroom.id)
The re-query is necessary because Dream instantiates model classes from the type column at query time. After update(), the in-memory object is still the old class, with its serializers and behavior.
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'] },
})
bulk creation from a request-body array
For endpoints that create many STI children in one request — { items: [{ type: 'Bedroom', position: 1, ... }, { type: 'Kitchen', position: 2, ... }] } — extract each entry's params against the parent's safe columns with { key, array: true }. The framework still strips type (and foreign keys such as placeId) per the standard exclusions, so cast each item's type from the raw request body with Params.cast against the enum, then dispatch through an exhaustive switch:
import { Params } from '@rvoh/psychic'
// Body: { items: [{ type: 'Bedroom', position: 1, ... }, { type: 'Kitchen', position: 2, ... }] }
const itemsParams = this.extractParams(Room, ['name', 'position', 'bedTypes'], { key: 'items', array: true })
const rawItems = (this.params as { items?: object[] }).items ?? []
for (const [i, params] of itemsParams.entries()) {
const roomType = Params.cast(rawItems[i] ?? {}, 'type', 'string', { enum: RoomTypesEnumValues })
switch (roomType) {
case 'Bathroom': await Bathroom.create({ place: this.currentPlace, ...params }); break
case 'Bedroom': await Bedroom.create({ place: this.currentPlace, ...params }); break
case 'Kitchen': await Kitchen.create({ place: this.currentPlace, ...params }); break
case 'Den': await Den.create({ place: this.currentPlace, ...params }); break
case 'LivingRoom': await LivingRoom.create({ place: this.currentPlace, ...params }); break
default: {
const _never: never = roomType
throw new Error(`Unhandled RoomTypesEnum: ${_never as string}`)
}
}
}
Params.cast rejects a missing or unknown type the same way castParam does, with a 400. Wrap the loop in ApplicationModel.transaction(async txn => { ... }) only when the action genuinely needs atomicity — the extra overhead isn't free — and then bind each create to it (Bedroom.txn(txn).create(...)), as every write inside a transaction must be.
serializers
The base serializers --sti-base-serializer generates are intentionally generic: each takes a StiChildClass parameter, and the summary serializer renders type right after id, with an enum holding only the child's class name, which the default serializer inherits. Each child passes its own class, which narrows the OpenAPI type enum to that child's name and lets the child's serializer add its own columns. 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')
The single-value type is what keeps child shapes apart over HTTP. @OpenAPI(Room, ...) renders a response as an anyOf with one schema per child, sorted by class name, and fastJsonStringify writes each row with the first schema it satisfies. Each schema requires its own attributes but tolerates extra ones, so without a type that only one child accepts, Den (which adds nothing) accepts a Kitchen row and writes it without appliances. The single-value type enum makes every schema reject the other children's rows, and it is what lets a client narrow the union.
The failure is silent in a unit spec — direct serializer rendering still looks correct — and only shows up in the response fastJsonStringify produces over HTTP. If you see that exact symptom (direct render correct, HTTP response missing STI child fields), suspect a serializer that lacks the type / StiChildClass shape before suspecting Dream model instantiation or preloadFor.
The default and summary variants above are always generated for you. AdminSerializer/AdminSummarySerializer and InternalSerializer/InternalSummarySerializer are also generator-produced — via --admin-serializers / --internal-serializers on g:model, or automatically when a resource is generated under an Admin/ or Internal/ controller namespace — and already carry the STI base shape, with the type discriminator on each summary serializer. You never hand-write those variants.
A hand-added variant like forGuests (there is no guest-serializer flag) is the case that needs the manual replication shown above. Register it on every child, not just the ones you're actively working on — the generated schema's serializer-key list is the intersection across the models sharing it, so a variant only reaches the base class's DreamSerializerKey once every child has registered it. If a child is missing it, the type error surfaces on Room.preloadFor('forGuests'), but the fix belongs on the child that lacks the key. Run pnpm psy sync after adding the variant everywhere.
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