The @Computed decorator declares a field whose value is computed in the database from an expression, then merged onto each row at query time. The expression can be a row-local computation (derived from other fields of the same row) or a per-row subquery, such as an aggregate over another table. Because the merge happens before sorting and filtering, computed fields are natively sortable, filterable, and pageable in DB — no fetch-all or in-memory sorting required.
Computed fields are read-only: the value is produced by the query and is never persisted on the controller's table. Declaring a field as computed automatically sets its access mode to read-only and forces non-indexed sorting, regardless of decorator order.
The expression receives the row proxy and the schema instance of the controller's table. It must return a value built from the query builder (ValueProxy operations or a subquery), not plain JavaScript computed at request time.
import { Computed } from "@antelopejs/interface-data-api/metadata";
@RegisterTable("groups")
class Group extends Table {
@Field("string")
declare _id: string;
@Field("string")
declare name: string;
}
@RegisterTable("devices")
class Device extends Table {
@Field("string")
declare _id: string;
@Index()
@Field("string")
@Relation({ to: () => Group })
declare group_id: string;
}
@RegisterDataController()
class GroupAPI extends DataController(
Group,
DefaultRoutes.All,
Controller("/groups"),
) {
@ModelReference()
@Model(GroupModel)
declare groupModel: GroupModel;
@Listable()
@Access(AccessMode.ReadOnly)
declare _id: string;
@Listable()
@Access(AccessMode.ReadWrite)
declare name: string;
// Aggregate: number of devices referencing this group, computed in DB
@Listable()
@Computed(
(row, db) =>
db
.table("devices")
.getAll(row.key("_id"), "group_id")
.count(),
{ default: 0 },
)
@Sortable()
declare devices_count: number;
// Row-local expression
@Listable()
@Computed((row) => (row.key("name") as ValueProxy<string>).concat("!"))
declare display_name: string;
}A GET request to /groups/get?id=group-123 returns the computed values at the top level:
{
"_id": "group-123",
"name": "Alpha",
"devices_count": 3,
"display_name": "Alpha!"
}@Computed(expr, options?)| Parameter | Type | Required | Description |
|---|---|---|---|
expr |
(row, db) => expression |
Yes | Expression receiving the row proxy and the schema instance; returns a ValueProxy expression or a subquery (Datum) |
options.default |
unknown |
No | Fallback merged over the computed value when the expression yields null |
Aggregate subqueries yield null instead of their natural zero value when the underlying set is empty (a count() over a group with no devices returns null, not 0). Calling .default(0) inside the subquery expression does not help: the aggregation produces no document at all, so there is nothing to apply the default to. The options.default value solves this by being applied at the row level, after the merge:
@Computed((row, db) => db.table("devices").getAll(row.key("_id"), "group_id").count(), {
default: 0,
})
declare devices_count: number;Without it, rows with an empty aggregate carry null, which breaks equality filters (filter_devices_count=eq:0) and makes sort ordering depend on BSON type ordering.
Computed fields combine with @Sortable and @Filter like native columns. The framework injects the computation lazily, only when and where the value is needed:
- A computed field referenced by an active filter is merged before filters run.
- A computed field used as the sort key is merged after the total count is captured and before the non-indexed
orderBy, so the aggregate is not paid twice. - A computed field requested only for display (in the pluck, neither sorted nor filtered) is merged after pagination (
slice), so it is computed only for the returned page, not the whole collection.
Sorting and pagination both stay in the database: sorting by a computed aggregate with limit/offset produces correct pages and a constant total.
Computed fields are always sorted without a database index (@Sortable is forced to non-indexed), since the value does not exist as a stored column.
Filter values arrive as strings from the query string. The default filter compares them as-is, which never matches a numeric computed value ("0" is not 0 in BSON). Provide a custom filter function that coerces the value:
@Computed((row, db) => db.table("devices").getAll(row.key("_id"), "group_id").count(), {
default: 0,
})
@Filter((_context, proxy, _key, value) =>
(proxy as ValueProxy<number>).eq(parseFloat(value)),
)
declare devices_count: number;| Aspect | @Computed |
@Joined |
|---|---|---|
| Source | Arbitrary expression: row-local computation or per-row subquery | One scalar field from another table matched by a foreign key |
| Typical use | Aggregates (count, sum), derived values |
Flattening a referenced record's field |
| Access | Read-only (enforced) | Read-only (enforced) |
| Sort / filter | Native, lazily injected only where needed | Native, always applied before sort/filter |
@Joined remains the simpler choice for the 1:1 flattening case; @Computed generalizes the same merge mechanism to arbitrary expressions.
The aggregate form relies on the database driver translating a per-row subquery inside a merge into a correlated lookup ($lookup with let/pipeline on MongoDB). This is supported by @antelopejs/mongodb >= 1.2.1. Row-local expressions only use standard expression compilation and work on any driver.
See the joined fields documentation for the dedicated 1:1 flattening decorator.