Skip to main content

@deco.Virtual

The @deco.Virtual decorator enables setting of fields as if they corresponded to columns in the model's table so they can be passed to new, create, and update. It requires an OpenAPI type argument that defines both the serializer OpenAPI shape and the request body OpenAPI shape (in Psychic).

Run pnpm psy sync after adding a @deco.Virtual() decorator (or changing its type argument) — that sync is separate from the one a migration triggers, and without it create()/update() reject the virtual attribute at build time while every runtime spec passes.

Rules

Getters and setters must be synchronous. Dream calls them inline during attribute access and assignment — an async getter will return a Promise instead of the value, and Dream has no mechanism to await it.

The decorator goes on whichever accessor is declared first. If you declare the getter before the setter, the decorator goes on the getter. If you declare a simple field (no getter/setter), the decorator goes on the field.

Use DreamColumn<Model, 'columnName'> for the getter return type and setter parameter type when the virtual value is derived from (or stored into) an existing database column. The column type is the source of truth — don't use a hand-written number | null when DreamColumn<BodyMeasurement, 'grams'> already resolves to the correct type and will update automatically if the schema changes.

For example, in the first example below, one could call await bodyMeasurement.update({ lbs: 180.1 }), and 180.1 will be passed into the lbs setter, which then translates lbs to grams to be stored in the grams column in the metrics table.

And in the second example below, one could call await user.update({ password }), and, in the BeforeSave lifecycle hook, the password would be hashed into hashedPassword. (This is just an example to illustrate using the Virtual decorator on a simple field; it might be better design to use the getter/setter pattern for password, with the getter simply returning undefined.)

import { DreamColumn } from '@rvoh/dream/types'

class BodyMeasurement extends ApplicationModel {
// Decorator on the getter (declared first). Return type uses DreamColumn so
// it stays in sync with the schema automatically.
@deco.Virtual(['number', 'null'])
public get lbs(): DreamColumn<BodyMeasurement, 'grams'> {
const grams = this.getAttribute('grams')
return grams === null ? null : gramsToLbs(grams)
}

public set lbs(lbs: DreamColumn<BodyMeasurement, 'grams'>) {
this.setAttribute('grams', lbs === null ? null : lbsToGrams(lbs))
}

@deco.Virtual(['number', 'null'])
public get kilograms(): DreamColumn<BodyMeasurement, 'grams'> {
const grams = this.getAttribute('grams')
return grams === null ? null : gramsToKilograms(grams)
}

public set kilograms(kg: DreamColumn<BodyMeasurement, 'grams'>) {
this.setAttribute('grams', kg === null ? null : kilogramsToGrams(kg))
}
}
import argon2 from 'argon2'

class User extends ApplicationModel {
@deco.Virtual('string')
public password: string | undefined

@deco.BeforeSave()
public async hashPassword(this: User) {
if (this.password) {
this.setAttribute(
'passwordDigest',
await argon2.hash(this.password)
)
this.password = undefined
}
}
}

The type argument accepts any OpenAPI shorthand primitive type (like 'string', 'number', 'integer', 'boolean') or a more complex OpenAPI schema body shorthand for representing objects and arrays. The full set of virtual types: 'boolean', 'date-time', 'date', 'integer', 'null', 'number', 'string', 'decimal', 'json', and their array variants ('boolean[]', 'date-time[]', 'date[]', 'decimal[]', 'integer[]', 'number[]', 'string[]'). Use an array pairing with 'null' (e.g. ['string', 'null']) for a nullable virtual.

The type argument to @deco.Virtual(...) sets the OpenAPI shape wherever the attribute is surfaced in a serializer or model-derived OpenAPI request body, and it's the type extractParams enforces at runtime and in TypeScript — just as a real column's OpenAPI shape is derived from the database schema, a virtual's shape is derived from the decorator's type argument. Don't pass an explicit openapi: option to .attribute(...) or .delegatedAttribute(...) when surfacing a virtual — it overrides the auto-inferred shape and creates a place for the spec to drift when the virtual's declared type changes.

Virtual getters and setters must be synchronous — reach for an association instead

Dream reads a virtual getter as a plain attribute, not as a Promise. An async getter returns a Promise instead of the declared type, which silently breaks serialization and is invisible to preloadFor's DSL introspection. If you need data that requires an await (a DB lookup, a remote URL, a cross-association walk), express it as a real association (@deco.HasOne/@deco.BelongsTo) and surface it via .delegatedAttribute(...) or .rendersOne(...) in the serializer instead — never via a virtual.