Skip to main content

associations

rendersOne

Use .rendersOne() to include a single associated model in your serialized output:

export const RoomWithPlaceSerializer = (room: Room) =>
DreamSerializer(Room, room).attribute('id').attribute('name').rendersOne('place')

rendersOne.optional

Pass optional: true when the association can be null at render time — but only when there's no other way for rendersOne to know that. For a BelongsTo association, the model's own @deco.BelongsTo('User', { optional: true }) declaration is already the canonical source of truth for nullability, and rendersOne auto-infers the same anyOf: [{ $ref: ... }, { type: 'null' }] OpenAPI shape from it:

// Model
@deco.BelongsTo('User', { optional: true })
public approver: User | null

// Serializer — no `optional` needed; auto-inferred from the BelongsTo declaration.
.rendersOne('approver')

Don't restate optional: true on a rendersOne for a BelongsTo — it duplicates the model's declaration, and changing it (passing optional: true when the BelongsTo isn't optional, or vice versa) is almost always a mistake, since the model governs what can actually be null at runtime. Reach for explicit optional: true only when the association isn't a BelongsTo — a HasOne that may be absent, for example — where there's no model-side nullability for rendersOne to infer:

.rendersOne('approver', { optional: true })

optional is purely an OpenAPI nullability marker — the key is always rendered, and rendersOne has no required: false counterpart. If you need the key omitted entirely, reshape the serializer (a customAttribute, or a different serializer variant) instead. optional: true also has no effect on an association that was never loaded: rendering a NonLoadedAssociation still throws regardless of optional, so load it with preloadFor or loadFor either way. See the BelongsTo optional contract for the model-side rules this defers to.

rendersMany

Use .rendersMany() to include an array of associated models:

export const PlaceForGuestsSerializer = (place: Place) =>
DreamSerializer(Place, place)
.attribute('id')
.delegatedAttribute('currentLocalizedText', 'title', { openapi: 'string' })
.rendersMany('rooms', { serializerKey: 'forGuests' })

serializerKey

By default, associations render using the 'default' serializer declared on the associated model. Pass serializerKey to use a different one:

serializerKey does not cascade. If a parent serializer uses a non-default key, nested rendersOne and rendersMany calls still use the associated model's default serializer unless you pass serializerKey at that nested call too.

export const PlaceDetailSerializer = (place: Place) =>
DreamSerializer(Place, place)
.attribute('id')
.attribute('name')
.rendersMany('rooms', { serializerKey: 'forGuests' })
.rendersMany('bookings') // uses 'default'

serializer

Instead of referencing a serializer by name with serializerKey, you can pass a serializer function directly. This is particularly useful when you need to serialize something that isn't a Dream model — like transforming an array of enum values into objects:

import { ObjectSerializer } from '@rvoh/dream'

// serializer for a single bed type enum value
export const BedTypeSerializer = (bedType: BedTypesEnum, passthrough: { locale: LocalesEnum }) =>
ObjectSerializer({ bedType }, passthrough)
.attribute('bedType', { as: 'value', openapi: { type: 'string', enum: BedTypesEnumValues } })
.customAttribute('label', () => i18n(passthrough.locale, `rooms.Bedroom.bedTypes.${bedType}`), {
openapi: 'string',
})

// parent serializer that uses it
export const RoomBedroomForGuestsSerializer = (
roomBedroom: Bedroom,
passthrough: { locale: LocalesEnum },
) =>
DreamSerializer(Bedroom, roomBedroom, passthrough)
.attribute('id')
.attribute('type')
.rendersMany('bedTypes', { serializer: BedTypeSerializer })

In this example, bedTypes is an array of enum values (e.g., ['cot', 'bunk']). The BedTypeSerializer uses ObjectSerializer to transform each enum value into an object with both the value (the enum) and a label (the localized string). The passthrough data containing the locale is automatically passed from the parent serializer to each BedTypeSerializer invocation.

Similarly, you can use rendersOne with a serializer function for single values:

import { ObjectSerializer } from '@rvoh/dream'
import { LocalesEnum, BathOrShowerStylesEnum, BathOrShowerStylesEnumValues } from '@src/types/db.js'
import i18n from '@src/utils/i18n.js'

export const BathOrShowerStyleSerializer = (
bathOrShowerStyle: BathOrShowerStylesEnum,
passthrough: { locale: LocalesEnum },
) =>
ObjectSerializer({ bathOrShowerStyle }, passthrough)
.attribute('bathOrShowerStyle', {
as: 'value',
openapi: { type: 'string', enum: BathOrShowerStylesEnumValues },
})
.customAttribute(
'label',
() => i18n(passthrough.locale, `rooms.Bathroom.bathOrShowerStyles.${bathOrShowerStyle}`),
{
openapi: 'string',
},
)

export const RoomBathroomForGuestsSerializer = (
roomBathroom: Bathroom,
passthrough: { locale: LocalesEnum },
) =>
DreamSerializer(Bathroom, roomBathroom, passthrough)
.attribute('id')
.attribute('type')
.rendersOne('bathOrShowerStyle', { serializer: BathOrShowerStyleSerializer })
info

When using ObjectSerializer for nested serializers, you must explicitly provide the OpenAPI shape for each attribute since ObjectSerializer doesn't have access to database schema information like DreamSerializer does.

as

Rename the association in the output:

export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place).attribute('id').rendersMany('rooms', { as: 'accommodations' })

// renders: { id: 1234, accommodations: [...] }

rendersOne.flatten

When flatten: true is included, the serialized association's attributes get flattened into the parent object:

export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place).attribute('id').rendersOne('currentLocalizedText', {
flatten: true,
serializerKey: 'forPlaces',
})

// renders: { id: 1234, title: 'My localized title', markdown: 'My localized markdown' }

When you only need a property or two from an association, a delegatedAttribute is usually simpler than creating a new named serializer just to flatten it.

attribute shadowing

Flattening merges the associated record's attributes directly into the parent response. When the flattened association has an attribute with the same name as one on the parent serializer, the flattened association's attribute wins — it overwrites the parent's. The most common collision is id: both the parent and the flattened association typically have one, and the flattened one takes over.

This shows up as the API response returning the associated record's id instead of the parent's, frontend routing or specs picking up the wrong identity, or other same-named attributes (name, createdAt, …) having unexpected values.

There are two ways to fix it, depending on which attribute you actually need:

When the flattened association's colliding attribute isn't needed, give it a dedicated flattenable serializer that omits the attribute, and build the normal (non-flattened) serializer from that flattenable one, adding the attribute back:

// GuestSerializer.ts
export const GuestSummarySerializer = (guest: Guest) =>
DreamSerializer(Guest, guest).attribute('id').attribute('name')

export const FlattenableGuestSerializer = (guest: Guest) =>
DreamSerializer(Guest, guest).attribute('name').attribute('bio')

export const GuestSerializer = (guest: Guest) => FlattenableGuestSerializer(guest).attribute('id')

// BookingSerializer.ts — flattens guest data without the guest's id
export const BookingSerializer = (booking: Booking) =>
DreamSerializer(Booking, booking)
.attribute('id')
.rendersOne('guest', { serializer: FlattenableGuestSerializer, flatten: true })

This breaks the usual pattern where the default serializer extends the summary serializer, so some attributes may need to be duplicated across the flattenable and non-flattenable variants.

When the parent's colliding attribute isn't needed, simply omit it from the parent serializer instead. This is the common case for join models, where the join model's own id isn't useful to the consumer and the real identity is the flattened association's id:

// HostPlace only contributes `position`; Place's attributes (including id) are flattened in
export const HostPlaceSerializer = (hostPlace: HostPlace) =>
DreamSerializer(HostPlace, hostPlace).attribute('position').rendersOne('place', { flatten: true })

When a serializer flattens, write controller specs against the serialized API contract (the flattened shape), not the underlying model's property names.

rendersOne, rendersMany, and STI

A serializer built for an STI base class typically takes the STI child class as a generic type parameter, so it can resolve the correct schema per child. When a child serializer extends that base and calls rendersOne, rendersMany, or delegatedAttribute, pass the STI child class explicitly as the generic parameter — without it, TypeScript can't resolve the association's types, because the base serializer's generic context is lost at the call site:

// Correct — <Bedroom> lets rendersMany resolve Bedroom's associations
export const RoomBedroomForGuestsSerializer = (bedroom: Bedroom, passthrough: { locale: LocalesEnum }) =>
RoomForGuestsSerializer(Bedroom, bedroom, passthrough).rendersMany<Bedroom>('bedTypes', {
serializer: BedTypeSerializer,
})

// Wrong — omitting the generic causes type errors
.rendersMany('bedTypes', { serializer: BedTypeSerializer })

.attribute() infers its type without a generic argument, and .customAttribute() doesn't do type inference at all, so neither one needs this. See single table inheritance for the base/child serializer pattern itself.

loading associations

Associations need to be loaded before serialization. The easiest way is with preloadFor, which automatically figures out what to load based on the serializer — including nested rendersOne, rendersMany, and delegatedAttribute associations:

const places = await Place.preloadFor('forGuests').all()

Prefer preloadFor (or loadFor on an existing instance) over a hand-written preload(...) chain. Manual preload is fragile — it's easy to miss a nested dependency, which surfaces as a NonLoadedAssociation error at serialization time rather than at the query. preloadFor also adapts automatically as serializers evolve: add a rendersMany to a serializer, and every controller using preloadFor picks it up without a code change. A manual preload chain won't. Manual preload/load is still the right tool outside of serialization — for example, loading associations to support business logic in a service.