Virtual & Computed Fields
Attributes that live only on the model instance - computed in JavaScript from other fields, never stored in a database column.A virtual field is declared with DataTypes.VIRTUAL (or DataTypes.VIRTUALSTRING). It produces no column at sync() time and is stripped out of every INSERT/UPDATE, but it participates in reads through a getter and in writes through a setter. Getters are perfect for values you can derive on demand (fullName, totalWithTax, inStock); setters let a single assignment fan out into several real columns.Declaring a virtual field#
There are two equivalent shapes. You can pass the accessors to the type factory:import { Prorm, DataTypes } from 'ts-prorm-orm';
const prorm = new Prorm({ dialect: 'sqlite', storage: ':memory:' });
await prorm.connect();
const User = prorm.define('User', {
firstName: { type: DataTypes.STRING(100), allowNull: false },
lastName: { type: DataTypes.STRING(100), allowNull: false },
fullName: {
type: DataTypes.VIRTUAL({
get() {
return `${this.getDataValue('firstName')} ${this.getDataValue('lastName')}`;
},
dependencies: ['firstName', 'lastName'],
}),
},
});
await prorm.sync({ force: true });
const user = await User.create({ firstName: 'John', lastName: 'Doe' });
console.log(user.fullName); // "John Doe"
console.log(user.get('fullName')); // "John Doe"
The table has no fullName column, and the INSERT never mentions it:Or place get / set directly on the attribute alongside a bare DataTypes.VIRTUAL:const User = prorm.define('User', {
firstName: { type: DataTypes.STRING() },
lastName: { type: DataTypes.STRING() },
fullName: {
type: DataTypes.VIRTUAL(),
get(this: { firstName: string; lastName: string }) {
return `${this.firstName} ${this.lastName}`;
},
},
});
Both forms resolve to the same accessors. Inside a getter or setter this is the model instance, so you can read siblings either through the proxy (this.firstName) or explicitly with this.getDataValue('firstName'). The explicit form is safest inside virtual getters because it always returns the stored value without re-triggering another accessor.You can also attach a virtual field to an already-defined model with defineAttribute:User.defineAttribute('initials', {
type: DataTypes.VIRTUAL({
get() {
return `${this.getDataValue('firstName')[0]}${this.getDataValue('lastName')[0]}`;
},
dependencies: ['firstName', 'lastName'],
}),
});
Reading: getters#
A getter runs every time the field is accessed - there is no caching, so it always reflects the current column values. This is why the dependencies array is documentation/reactivity metadata rather than a memoization key.const Product = prorm.define('Product', {
name: { type: DataTypes.STRING(200), allowNull: false },
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false },
quantity: { type: DataTypes.INTEGER, defaultValue: 0 },
});
Product.defineAttribute('totalValue', {
type: DataTypes.VIRTUAL({
get() {
const price = parseFloat(this.getDataValue('price') || 0);
const qty = parseInt(this.getDataValue('quantity') || 0);
return price * qty;
},
dependencies: ['price', 'quantity'],
}),
});
Product.defineAttribute('inStock', {
type: DataTypes.VIRTUAL({
get() {
return parseInt(this.getDataValue('quantity') || 0) > 0;
},
dependencies: ['quantity'],
}),
});
await prorm.sync({ force: true });
const laptop = await Product.create({ name: 'Laptop', price: 999.99, quantity: 5 });
console.log(laptop.get('totalValue')); // 4999.95
console.log(laptop.get('inStock')); // true
Because the getter recomputes on every read, updating a dependency updates the virtual value automatically:await laptop.update({ quantity: 0 });
console.log(laptop.get('totalValue')); // 0
console.log(laptop.get('inStock')); // false
A virtual field is not limited to strings or numbers - return booleans, arrays, or objects as needed.Virtual fields that depend on other virtual fields#
A getter may call this.get('otherVirtual'), so computed fields can be layered. List every underlying real column in dependencies so the dependency graph stays accurate.Product.defineAttribute('taxRate', { type: DataTypes.DECIMAL(5, 2), defaultValue: 0.1 });
Product.defineAttribute('totalWithTax', {
type: DataTypes.VIRTUAL({
get() {
const total = this.get('totalValue') as number; // another virtual
const tax = parseFloat(this.getDataValue('taxRate') || 0);
return total * (1 + tax);
},
dependencies: ['price', 'quantity', 'taxRate'],
}),
});
const item = await Product.create({ name: 'Desk', price: 100, quantity: 10, taxRate: 0.1 });
console.log(item.get('totalWithTax')); // 1100 (1000 * 1.1)
await item.update({ quantity: 15, taxRate: 0.2 });
console.log(item.get('totalWithTax')); // 1800 (1500 * 1.2)
totalWithTax reads totalValue, which itself reads price and quantity. Nothing is cached, so each layer stays consistent after an update.Writing: setters that derive columns#
A setter lets you assign to a virtual field and have it write real columns via setDataValue. setDataValue writes straight into dataValues, marks the key as changed, and flags the instance dirty - so the derived columns persist on the next save().User.defineAttribute('fullName', {
type: DataTypes.VIRTUAL({
get() {
return `${this.getDataValue('firstName')} ${this.getDataValue('lastName')}`;
},
set(value: string) {
const parts = value.split(' ');
this.setDataValue('firstName', parts[0]);
this.setDataValue('lastName', parts.slice(1).join(' ') || '');
},
dependencies: ['firstName', 'lastName'],
}),
});
const u = User.build({ email: 'jane@example.com' });
u.set('fullName', 'Jane Smith');
console.log(u.getDataValue('firstName')); // "Jane"
console.log(u.getDataValue('lastName')); // "Smith"
await u.save();
// firstName / lastName are persisted; fullName was never a column.
The same setter runs when the virtual is passed to create(), and when it is
assigned on a loaded row before save():const jane = await User.create({ fullName: 'Jane Smith', email: 'jane@example.com' });
const found = await User.findOne({ where: { email: 'jane@example.com' } });
found.fullName = 'Jane Doe-Smith';
await found.save();
Assigning through the proxy triggers the setter too, and setters may write to sibling fields through the proxy instead of setDataValue:const User = prorm.define('User', {
name: { type: DataTypes.STRING() },
nameParts: {
type: DataTypes.VIRTUAL(),
get(this: { name?: string }) {
return this.name?.split(' ');
},
set(this: { name?: string }, value: string[]) {
this.name = value.join(' '); // writes the real `name` column
},
},
});
const created = await User.create({ nameParts: ['John', 'Doe'] });
console.log(created.name); // "John Doe"
You can pass a virtual field to create() and it will run its setter to populate the underlying columns, while the virtual itself is filtered out of the SQL.You cannot query by a virtual field#
A virtual has no column, so it cannot appear in anything the database
evaluates. Prorm does not catch this for you — the name is sent as a column and
the database rejects it:await User.findAll({ where: { fullName: 'John Doe' } });
await User.findAll({ attributes: ['id', 'fullName'] });
SELECT * FROM "users" WHERE "fullName" = 'John Doe'
-> Error: no such column: "fullName"
SELECT "id", "fullName" FROM "users"
-> Error: no such column: "fullName"
Filter on the real columns the virtual is derived from instead:- await User.findAll({ where: { fullName: 'John Doe' } });
+ await User.findAll({ where: { firstName: 'John', lastName: 'Doe' } });
[!TIP]
When you need to filter or sort by a derived value — a full name search, a
total — store it: a real column kept in step by a beforeSave
hook, or a database-generated column (see
Schema objects). A virtual is for presentation.
VIRTUALSTRING#
DataTypes.VIRTUALSTRING behaves like VIRTUAL but is specialised for string values and accepts an optional length for validation. Use it when the computed value is always a string.const User = prorm.define('User', {
firstName: DataTypes.STRING,
lastName: DataTypes.STRING,
displayName: {
type: DataTypes.VIRTUALSTRING({
get() {
return `${this.getDataValue('firstName')} ${this.getDataValue('lastName')}`.trim();
},
length: 255,
dependencies: ['firstName', 'lastName'],
}),
},
});
Serialization: virtual fields and toJSON#
Virtual fields are excluded from toJSON() by default - toJSON walks the real columns and skips anything whose type key is VIRTUAL or VIRTUALSTRING.const user = await User.findOne({ where: { email: 'jane@example.com' } });
const json = user.toJSON();
console.log(json.firstName); // "Jane"
console.log(json.fullName); // undefined <-- virtuals are not serialized
When you want a virtual value in your serialized output, add it explicitly after toJSON():const payload = {
...user.toJSON(),
fullName: user.get('fullName'),
};
To include every virtual field programmatically, use the model helpers. getVirtualFields() lists the virtual attribute names and filterVirtualFields(values) returns a copy of an object with the virtual keys removed (the same filtering the ORM applies before writing to the database):const virtuals = User.getVirtualFields(); // ['fullName', 'displayName', ...]
const enriched = { ...user.toJSON() };
for (const name of virtuals) {
enriched[name] = user.get(name);
}
[!WARNING]
A toJSON entry in a define() model's hooks is not called — toJSON()
still returns the columns only. Centralise the shape in a function instead:
export function serializeUser(user: any) {
return {
...user.toJSON(),
fullName: user.get('fullName'),
};
}
// res.json(serializeUser(user))
This also keeps the API's shape in one reviewed place, rather than letting every
new virtual leak into responses by default.Summary#
DataTypes.VIRTUAL / DataTypes.VIRTUALSTRING declare fields with no database column.
Accessors can be passed to the factory (DataTypes.VIRTUAL({ get, set })) or placed on the attribute; inside them this is the instance.
Getters run on every read, so a virtual always reflects live column values; dependencies documents the columns it derives from.
A getter may call this.get('otherVirtual') to compose virtual fields.
Setters use this.setDataValue(...) (or proxy assignment) to derive and persist real columns from one assignment.
Virtuals are stripped from every INSERT/UPDATE (filterVirtualFields) and from toJSON() output; add them back manually, or via getVirtualFields().
A virtual cannot be used in where, order or attributes — the database has no such column.
Modified at 2026-10-04 00:40:16