Skip to main content

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:

OptionRuntimeOpenAPI
optional: trueNo effect — the key always renders (null when the path is missing).Schema becomes anyOf: [schema, { type: 'null' }].
required: falseThe 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.