attributes
attribute
The attribute method includes a property from your source object in the serialized output.
export const PlaceSummarySerializer = (place: Place) =>
DreamSerializer(Place, place).attribute('id').attribute('name')
export const PlaceSerializer = (place: Place) =>
PlaceSummarySerializer(place).attribute('style').attribute('sleeps')
// PlaceSummarySerializer renders: { id: 1234, name: 'My place' }
// PlaceSerializer renders: { id: 1234, name: 'My place', style: 'cabin', sleeps: 5 }
CalendarDate and DateTime attributes (even arrays of them) are automatically converted to their ISO values during serialization.
openapi
When serializing a Dream model with DreamSerializer, the OpenAPI shape of database column attributes is determined automatically (except for json and jsonb columns). You can still add an OpenAPI description if you want:
.attribute('name', { openapi: { description: 'The name of the Place' } })
With ObjectSerializer, you always need to provide the openapi option, since there's no schema to pull from. Same goes for virtual attributes on Dream models — even with DreamSerializer, virtual attributes need an explicit openapi shape:
export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place)
.attribute('id')
.attribute('myVirtualAttribute', { openapi: ['string', 'null'] })
For json/jsonb columns, you'll need to spell out the full OpenAPI shape:
export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place)
.attribute('id')
.attribute('myJsonAttribute', {
openapi: {
type: 'object',
properties: {
label: 'string',
value: 'decimal',
},
},
})
default
The default option provides a fallback when the attribute is null or undefined. Default values go through the same transformations as regular data (decimals get rounded, dates get converted to ISO format, etc.):
export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place)
.attribute('id')
.attribute('name', { default: 'unnamed' })
.attribute('sleeps', { default: 0 })
as
Use the as option to rename the attribute in the output:
export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place).attribute('id').attribute('sleeps', { as: 'accommodates' })
// renders: { id: 1234, accommodates: 5 }
precision
Decimal values can be automatically rounded with the precision option:
export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place).attribute('id').attribute('rating', { precision: 1 })
// A place with rating 4.66666 renders: { id: 1234, rating: 4.7 }
delegatedAttribute
The delegatedAttribute method reaches into a nested object or association and pulls an attribute into the serialized output. Associations used by delegatedAttribute are automatically loaded by preloadFor:
export const PlaceSummaryForGuestsSerializer = (place: Place) =>
DreamSerializer(Place, place)
.attribute('id')
.delegatedAttribute('currentLocalizedText', 'title', { openapi: 'string' })
// A place with currentLocalizedText { title: 'Hello world' } renders:
// { id: 1234, title: 'Hello world' }
delegatedAttribute.default
All options from attribute are supported on delegatedAttribute. The default kicks in whether the association itself is null or the attribute on the association is null:
.delegatedAttribute('currentLocalizedText', 'title', {
openapi: 'string',
default: 'Untitled',
})
// A place without a currentLocalizedText, or one with { title: null }, renders:
// { id: 1234, title: 'Untitled' }
delegatedAttribute.optional and delegatedAttribute.required
When the delegated-through path can resolve to undefined or null — a missing HasOne, an absent JSON sub-key, and so on — optional and required: false control what consumers see. They govern different layers and aren't interchangeable:
| Option | Runtime | OpenAPI |
|---|---|---|
optional: true | No effect — the key always renders (null when the path is missing). | Schema becomes anyOf: [schema, { type: 'null' }]. |
required: false | The key is omitted from the response entirely when the resolved value is undefined. | The field is excluded from the containing schema's required[]. |
default: <value> | Substitutes the value when the resolved path is undefined. | No effect. |
At render time, Dream resolves in this order: the first non-null/non-undefined value from the target path; else default if one was provided; else omit the key if required: false; else render null.
Both options work the same way across regular columns, virtual columns, JSON/JSONB columns, and STI type discriminators — choose based on what you want consumers to see, not what kind of column sits behind the path. A @deco.BelongsTo('Foo', { optional: true }) path already infers OpenAPI nullability on its own, so explicit optional: true is mostly needed for HasOne and other non-BelongsTo nullable paths. On an STI type discriminator, default isn't accepted — substituting a value there would make the response indistinguishable from an actual record of that type; use required: false instead so the response honestly signals absence.
Layering two delegatedAttributes onto the same key
Two delegatedAttribute calls can target the same output name, which gives you a "default value, optionally overridden by something more specific" pattern — a locale-specific translation falling back to a default-locale one, an org-level setting falling back to a global default, and so on. Attributes fold in declaration order, so a later directive overwrites an earlier one unless it's skipped:
.delegatedAttribute('fallbackAssociation', 'title', { openapi: 'string' })
.delegatedAttribute('specificAssociation', 'title', { openapi: 'string', required: false })
The first call is required, so it always writes title. The second uses required: false rather than optional: true: when specificAssociation is absent, the key is omitted instead of being overwritten with null, leaving the fallback's value in place. (optional: true would still write title: null and blank out the fallback.) Because the fallback directive is required, the merged OpenAPI schema keeps title as a required string, so this pattern doesn't change the generated client type. Both associations are automatically discovered and loaded by preloadFor — no controller change needed.
customAttribute
The customAttribute method is for arbitrary data transformation. It's especially handy for leveraging passthrough data. customAttribute always requires the openapi option:
export const PlaceForGuestsSerializer = (place: Place, passthrough: { locale: LocalesEnum }) =>
PlaceSummaryForGuestsSerializer(place).customAttribute(
'style',
() => i18n(passthrough.locale, `places.style.${place.style}`),
{
openapi: 'string',
}
)
See the passthrough documentation for how to set passthrough data on automatically rendered serializers.
customAttribute.flatten
When flatten: true is included, the attributes of the returned object get flattened into the parent:
const UserSerializer = (user: User) =>
DreamSerializer(User, user)
.attribute('id')
.customAttribute('profileInfo', () => ({ age: 30, city: 'Metropolis' }), {
flatten: true,
openapi: {
age: { type: 'integer' },
city: { type: 'string' },
},
})
// renders { id: 1, age: 30, city: 'Metropolis' }
customAttribute and preloadFor
customAttribute is for values computed from the model's own columns and passthrough data — a localized label, a formatted string, and so on. It is not discovered by preloadFor: preloadFor doesn't inspect the callback body, so if a customAttribute function reads an association (including a fallback like () => current?.title ?? fallback.title), that association won't be preloaded automatically, and rendering throws NonLoadedAssociation. Treat that error as the signal to reach for delegatedAttribute or a flattened rendersOne instead — both are discovered by preloadFor, so the association is loaded without any extra controller wiring. See layering two delegatedAttributes for the fallback-value case specifically.