addComputed and removeComputed
Computed fields are properties derived from other fields at instantiate/materialize time - the json-tology equivalent of Pydantic's @computed_field. Mark a property with "jt:computed": true in the schema and register a compute function. The function runs automatically during instantiate() and materialize().
JsonTology.addComputed
Declaration. Registers a compute function for a property marked "jt:computed": true. The function receives the fully structural-validated, coerced object and returns the computed value. Can be registered at construction time via computeds option or imperatively after construction. The compute function runs after structural validation and before the result is returned from instantiate() or materialize().
Use this when a property value is mechanically derivable from other fields - total from sum(items[].unitPrice * quantity), a displayTitle concatenating title and authors[0], a slug from title. Mark the property jt:computed: true in the schema to prevent callers from supplying it on input.
Don't use this when the rule is a cross-field validation constraint (use addInvariant instead). Don't confuse: computed fields derive values, invariants validate constraints.
Examples
Example 1: Order total derived from line items (construction time)
/**
* addComputed — Example 1: Derived `subtotal` on the canonical Order
*
* Demonstrates `addComputed` against the real registered `OrderSchema`
* from the bookstore. `addComputed` registers a derivation function for
* a property name; on every subsequent `instantiate()` the materializer
* invokes the fn with the live instance and writes the result onto the
* output value.
*
* The canonical `OrderSchema` does not declare `subtotal` as a property
* — it is layered on at runtime via `addComputed`, then read back from
* the materialized result. This keeps the canonical schema free of
* mandatory computed-field commitments while still demonstrating the
* surface against real registered data.
*/
import {
aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';
const computeSubtotal = (items: ReadonlyArray<{ 'quantity': number;
'unitPrice': { 'amount': number } }>): number => {
return items.reduce((sum, line) => {
return sum + (line.unitPrice.amount * line.quantity);
}, 0);
};
// addComputed returns a registry whose `Order` type is AUGMENTED with the
// computed `subtotal`, so subsequent `instantiate(OrderSchema.$id, …)` returns
// it fully typed — no cast needed to read the computed field.
const withSubtotal = bookstoreEntities.addComputed(
OrderSchema.$id,
'subtotal',
(order) => {
return computeSubtotal(order.orderLines);
}
);
const materialized = withSubtotal.instantiate(OrderSchema.$id, aboxFixtures.order);
const expected = computeSubtotal(aboxFixtures.order.orderLines);
console.assert(Math.abs(materialized.subtotal - expected) < 0.005);
console.log('order lines:', JSON.stringify(aboxFixtures.order.orderLines));
console.log('expected subtotal:', expected);
console.log('computed subtotal:', materialized.subtotal);
// removeComputed unregisters the fn; further instantiate() calls drop the field.
withSubtotal.removeComputed(OrderSchema.$id, 'subtotal');
const after = withSubtotal.instantiate(OrderSchema.$id, aboxFixtures.order);
console.assert(!Reflect.has(after, 'subtotal'));
console.log('subtotal after removeComputed:', Reflect.has(after, 'subtotal') ? after : '(field absent)');
Example 2: Coerce triggers the compute function
/**
* addComputed — Example 2: Coerce triggers the compute function
* Demonstrates: instantiate() invokes the registered compute fn, field is absent from input
*
* A `lineCount` computed field is layered onto OrderSchema at runtime.
* The canonical Bastian Balthazar Bux order fixture omits `lineCount` from
* input — the compute function derives it from `items.length`.
*/
import {
aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';
// addComputed returns a registry whose `Order` type is augmented with the
// computed `lineCount`, so `instantiate(OrderSchema.$id, …)` returns it typed.
const withLineCount = bookstoreEntities.addComputed(
OrderSchema.$id,
'lineCount',
(order) => {
return order.orderLines.length;
}
);
const order = withLineCount.instantiate(OrderSchema.$id, aboxFixtures.order);
// lineCount omitted from input — computed from orderLines.length.
const expectedLineCount = aboxFixtures.order.orderLines.length;
console.assert(Math.abs(order.lineCount - expectedLineCount) < 0.001);
console.log('input orderLines count:', aboxFixtures.order.orderLines.length);
console.log('computed lineCount:', order.lineCount);
// Cleanup: remove the computed field so other examples are unaffected.
withLineCount.removeComputed(OrderSchema.$id, 'lineCount');
Example 3: Imperative registration after construction
/**
* addComputed — Example 3: Imperative registration after construction
* Demonstrates: addComputed called against an already-constructed registry
*
* The canonical bookstore registry is constructed at module load time.
* This example adds and then removes a `discountedTotal` computed field
* imperatively — the pattern for dynamic discount tiers or feature-flag
* controlled derivations. Fixtures are from the Bastian Balthazar Bux
* order for the 1979 Thienemann first edition (€850.00).
*/
import {
aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';
// Imperative add after construction. addComputed returns a registry whose
// `Order` type is augmented with the computed `discountedTotal`.
const withDiscount = bookstoreEntities.addComputed(
OrderSchema.$id,
'discountedTotal',
(order) => {
return order.orderLines.reduce((sum, line) => {
return sum + (line.unitPrice.amount * line.quantity);
}, 0);
}
);
const order = withDiscount.instantiate(OrderSchema.$id, aboxFixtures.order);
const expectedTotal = aboxFixtures.order.orderLines.reduce(
(sum, line) => {
return sum + (line.unitPrice.amount * line.quantity);
},
0
);
console.assert(Math.abs(order.discountedTotal - expectedTotal) < 0.005);
console.log('expected discountedTotal:', expectedTotal);
console.log('computed discountedTotal:', order.discountedTotal);
// Cleanup so subsequent tests are not affected.
withDiscount.removeComputed(OrderSchema.$id, 'discountedTotal');
Behaviour table
| Situation | Result |
|---|---|
| Input omits the computed field | Value is derived and injected |
| Input supplies the computed field | InstantiationError with COMPUTED_INPUT_FORBIDDEN |
| Compute function throws | InstantiationError wrapping the original error |
Schema registered with jt:computed but no function | SchemaError with COMPUTED_FN_MISSING at registration |
Bad examples - what NOT to do
Anti-pattern 1: Using computed for validation logic
/**
* addComputed — Example 3: Imperative registration after construction
* Demonstrates: addComputed called against an already-constructed registry
*
* The canonical bookstore registry is constructed at module load time.
* This example adds and then removes a `discountedTotal` computed field
* imperatively — the pattern for dynamic discount tiers or feature-flag
* controlled derivations. Fixtures are from the Bastian Balthazar Bux
* order for the 1979 Thienemann first edition (€850.00).
*/
import {
aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';
// Imperative add after construction. addComputed returns a registry whose
// `Order` type is augmented with the computed `discountedTotal`.
const withDiscount = bookstoreEntities.addComputed(
OrderSchema.$id,
'discountedTotal',
(order) => {
return order.orderLines.reduce((sum, line) => {
return sum + (line.unitPrice.amount * line.quantity);
}, 0);
}
);
const order = withDiscount.instantiate(OrderSchema.$id, aboxFixtures.order);
const expectedTotal = aboxFixtures.order.orderLines.reduce(
(sum, line) => {
return sum + (line.unitPrice.amount * line.quantity);
},
0
);
console.assert(Math.abs(order.discountedTotal - expectedTotal) < 0.005);
console.log('expected discountedTotal:', expectedTotal);
console.log('computed discountedTotal:', order.discountedTotal);
// Cleanup so subsequent tests are not affected.
withDiscount.removeComputed(OrderSchema.$id, 'discountedTotal');
Comparison
// Schema authoring:
const schema = {
properties: {
orderTotal: { type: 'number', 'jt:computed': true },
},
} as const;
// Function registration:
jt.addComputed(ComputedOrderSchema.$id, 'orderTotal',
(order) => order.orderLines.reduce((s, l) => s + l.unitPrice * l.quantity, 0)
);
// Or at construction:
JsonTology.create({ computeds: { [schemaId]: { orderTotal: fn } } })// Zod uses .transform() to derive values:
const OrderSchema = z.object({ items: z.array(OrderLineSchema) })
.transform(data => ({
...data,
total: data.items.reduce((s, l) => s + l.unit_price * l.quantity, 0),
}));import * as v from 'valibot';
const OrderSchema = v.pipe(
v.object({ items: v.array(OrderLineSchema) }),
v.transform((data) => ({
...data,
total: data.items.reduce((s, l) => s + l.unitPrice * l.quantity, 0),
})),
);
// Limitation: Valibot has no registry of computed properties addressable
// by name; the derivation is baked into the pipe and cannot be added
// or removed against a registered schema after construction.import * as t from 'io-ts';
import type { Either } from 'fp-ts/Either';
// io-ts has no computed-field concept. Build a custom codec whose decode
// derives the field after the structural codec validates:
const ComputedOrderCodec = new t.Type<Order, OrderInput, unknown>(
'ComputedOrder',
(input): input is Order => true,
(input, ctx): Either<t.Errors, Order> => {
const decoded = baseOrderCodec.decode(input);
if (decoded._tag === 'Left') return decoded;
const order = decoded.right;
return t.success({
...order,
total: order.items.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0),
});
},
(output) => output as OrderInput,
);
// Limitation: no registry of named computeds; derivation is baked into the
// codec and cannot be added or removed by name after construction.// Not a first-class concept - compute manually after validation:
const validated = Value.Check(OrderSchema, data);
const order = { ...data, total: data.items.reduce((s, l) => s + l.unitPrice * l.quantity, 0) };// Not built in - apply after validation:
ajv.validate(orderSchema, data);
data.total = data.items.reduce((s, l) => s + l.unitPrice * l.quantity, 0);from pydantic import computed_field
class Order(BaseModel):
items: list[OrderLine]
@computed_field
@property
def total(self) -> float:
return sum(line.unit_price * line.quantity for line in self.items)// Limitation: feature not directly supported in Yup. See /comparisons for the matrix.// Limitation: feature not directly supported in Joi. See /comparisons for the matrix.// Limitation: feature not directly supported in Effect Schema. See /comparisons for the matrix.// Limitation: feature not directly supported in ArkType. See /comparisons for the matrix.// Limitation: feature not directly supported in Runtypes. See /comparisons for the matrix.Related
removeComputed- deregister a compute function- Invariants - cross-field validation rules (complements computed)
JsonTology.instantiate- the primary trigger for compute function evaluation
JsonTology.removeComputed
Declaration. Deregisters the compute function for the property name on schema schemaId. After removal, that property is no longer automatically computed. If the property remains in the schema with jt:computed: true, subsequent registrations or instantiate calls may produce a SchemaError.
Use this when schema configuration changes at runtime - replacing one computation strategy with another (discount tiers, promotional pricing), or toggling computed fields via feature flags.
Examples
Example 1: Replace a compute function
/**
* removeComputed — Example 1: Replace a compute function (discount tier swap)
* Demonstrates: removeComputed + re-addComputed for runtime fn replacement
*
* The standard totaliser is replaced by a gold-tier 10% discount totaliser for
* the Bastian Balthazar Bux order. After the example the field is removed so
* the canonical registry is left unmodified.
*/
import {
aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';
// Register standard totaliser, then replace it with a discounted one. Each
// addComputed returns a registry whose `Order` type carries `derivedTotal`.
const withTotal = bookstoreEntities.addComputed(
OrderSchema.$id,
'derivedTotal',
(order) => {
return order.orderLines.reduce((sum, line) => {
return sum + (line.unitPrice.amount * line.quantity);
}, 0);
}
);
// Remove the existing totaliser, then register a discounted one (10% off).
withTotal.removeComputed(OrderSchema.$id, 'derivedTotal');
const withDiscounted = withTotal.addComputed(
OrderSchema.$id,
'derivedTotal',
(order) => {
return order.orderLines.reduce((sum, line) => {
return sum + (line.unitPrice.amount * line.quantity);
}, 0) * 0.9;
}
);
const order = withDiscounted.instantiate(OrderSchema.$id, aboxFixtures.order);
const rawTotal = aboxFixtures.order.orderLines.reduce(
(sum, line) => {
return sum + (line.unitPrice.amount * line.quantity);
},
0
);
const expected = rawTotal * 0.9;
console.assert(Math.abs(order.derivedTotal - expected) < 0.005);
console.log('raw order total:', rawTotal);
console.log('10% discounted derivedTotal:', order.derivedTotal);
console.log('expected (raw * 0.9):', expected);
// Cleanup.
withDiscounted.removeComputed(OrderSchema.$id, 'derivedTotal');
Related
addComputed- register the compute function
See also
- Bookstore domain - where
OrderLineSchemais defined