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). On such a column, openapi adds only a description:

.attribute('name', { openapi: { description: 'The name of the Place' } })

A virtual attribute takes its shape from the type declared in @deco.Virtual(...), so it, too, is a plain .attribute('myVirtualAttribute') with no openapi type.

With ObjectSerializer, you always need to provide the openapi option, since there's no schema to pull from. Same goes for a plain getter on a Dream model — even with DreamSerializer, a getter needs an explicit openapi shape:

export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place)
.attribute('id')
.attribute('displayName', { openapi: 'string' }) // a getter on Place

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')

// A place with currentLocalizedText { title: 'Hello world' } renders:
// { id: 1234, title: 'Hello world' }

delegatedAttribute.default​

When the delegated-to property is a column, its OpenAPI shape is inferred, as with attribute; openapi is required only for a json/jsonb column or a non-Dream target. 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', { 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 made nullable (type: 'string' becomes type: ['string', 'null']; a $ref becomes anyOf: [{ $ref }, { 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 null or 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')
.delegatedAttribute('specificAssociation', 'title', { 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. The openapi option then describes the whole returned object:

export const UserSerializer = (user: User) =>
DreamSerializer(User, user)
.attribute('id')
.customAttribute('profileInfo', () => ({ age: 30, city: 'Metropolis' }), {
flatten: true,
openapi: {
type: 'object',
required: ['age', 'city'],
properties: {
age: { type: 'integer' },
city: { type: 'string' },
},
},
})

// renders { id: 1, age: 30, city: 'Metropolis' }

customAttribute returning a serializer​

To render a nested object, return an exported ObjectSerializer's builder from the callback and point openapi at that serializer with $serializer, which derives the schema:

.customAttribute(
'availabilitySummary',
() => PlaceAvailabilitySummaryViewSerializer(passthrough.availabilitySummary),
{ openapi: { $serializer: PlaceAvailabilitySummaryViewSerializer } },
)

Return the builder, not its .render() output or a raw object. Dream renders a returned builder with the outer render's passthrough and casing, which a hand-called .render() drops; a raw object goes out as-is, extra keys included.

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.