Core API
•7 min read
Derivations
Derivations compute values from facts with automatic dependency tracking.
Basic Derivations
Define derivations in the derive block of your module. Each derivation is a function that receives facts and returns a computed value:
import { createModule, t } from '@directive-run/core';
const userModule = createModule("user", {
schema: {
facts: {
firstName: t.string(),
lastName: t.string(),
age: t.number(),
},
derivations: {
fullName: t.string(),
isAdult: t.boolean(),
ageGroup: t.string(),
},
},
init: (facts) => {
facts.firstName = "";
facts.lastName = "";
facts.age = 0;
},
// Derivations auto-track which facts they read
derive: {
// Combine two facts into a display string
fullName: (facts) => `${facts.firstName} ${facts.lastName}`,
// Boolean derivation from a numeric fact
isAdult: (facts) => facts.age >= 18,
// Multi-branch logic for categorization
ageGroup: (facts) => {
if (facts.age < 13) {
return "child";
}
if (facts.age < 20) {
return "teen";
}
return "adult";
},
},
});
Auto-Tracking
Derivations automatically track which facts they access – no dependency arrays needed:
derive: {
// Automatically tracks firstName and lastName – ignores age
fullName: (facts) => `${facts.firstName} ${facts.lastName}`,
}
This derivation:
- Tracks
firstNameandlastName - Recomputes when either changes
- Ignores changes to
age
Facts: user cart promo
\ | /
\ | /
Derivations: isEligible | itemCount
\ | /
\ | /
Composed: checkoutReady
Accessing Derivations
Derive proxy
The most common way – access derivations as properties on system.derive:
const system = createSystem({ module: userModule });
system.start();
// Set some fact values
system.facts.firstName = "Jane";
system.facts.lastName = "Doe";
// Read derivations – recomputed automatically when facts change
system.derive.fullName; // "Jane Doe"
system.derive.isAdult; // false
system.derive.ageGroup; // "child"
For namespaced (multi-module) systems:
const system = createSystem({
modules: { user: userModule, cart: cartModule },
});
// Derivations are namespaced by module
system.derive.user.fullName; // "Jane Doe"
system.derive.cart.totalPrice; // 42.99
Read by ID
Read a derivation value by its string ID. This is the same value as system.derive.X, but useful when the derivation name is dynamic or when passing to framework adapters:
// Read by string ID (useful when name is dynamic)
system.read("fullName"); // "Jane Doe"
// Namespaced – dot syntax
system.read("user.fullName"); // "Jane Doe"
Subscribing to Derivations
system.subscribe()
Subscribe to one or more keys. The listener fires whenever any of the listed keys change. subscribe() auto-detects whether each key is a fact or derivation – you can mix both in a single call.
// Subscribe to a single derivation
const unsub = system.subscribe(["fullName"], () => {
console.log("Name changed:", system.derive.fullName);
});
// Subscribe to multiple derivations at once
const unsub2 = system.subscribe(["fullName", "isAdult"], () => {
console.log("Name or age status changed");
});
// Mix facts and derivations in one subscription
const unsub3 = system.subscribe(["age", "fullName"], () => {
console.log("Age fact or fullName derivation changed");
});
// Namespaced subscriptions
const unsub4 = system.subscribe(["user.fullName", "cart.totalPrice"], () => {
console.log("User or cart changed");
});
// Clean up when no longer needed
unsub();
system.watch()
Watch a single key (fact or derivation) with old and new values. Like subscribe(), watch() auto-detects the key type.
You can pass an optional equalityFn to control when the listener fires. By default, values are compared with ===. Provide a custom function to suppress notifications when the value is semantically unchanged (useful for object or array values):
// Watch a single derivation with old and new values
const unsub = system.watch("fullName", (newValue, previousValue) => {
console.log(`Name changed from "${previousValue}" to "${newValue}"`);
});
// Namespaced watch
const unsub2 = system.watch("user.ageGroup", (newVal, oldVal) => {
console.log(`Age group: ${oldVal} → ${newVal}`);
});
// Custom equality – only fire when the array length changes
const unsub3 = system.watch("activeUsers", (newVal, oldVal) => {
console.log(`Active user count: ${newVal.length}`);
}, { equalityFn: (a, b) => a.length === b.length });
unsub();
Composed Derivations
Facts: items tax shipping
│ │ │
Derivations: subtotal fees ◄──────┘
│ │
└────┬─────┘
▼
Composed: total
Derivations can depend on other derivations via the second parameter (derived):
derive: {
// Base derivations from facts
firstName: (facts) => facts.user?.name.split(' ')[0] ?? "",
lastName: (facts) => facts.user?.name.split(' ')[1] ?? "",
// Composed – depends on firstName and lastName derivations
initials: (facts, derived) =>
`${derived.firstName[0] ?? ""}${derived.lastName[0] ?? ""}`.toUpperCase(),
// Composed – depends on firstName derivation
greeting: (facts, derived) =>
`Hello, ${derived.firstName}!`,
}
The dependency graph is resolved automatically. If firstName changes, initials and greeting both recompute.
Circular dependencies
A derivation cannot depend on itself (directly or indirectly). Directive detects circular dependencies at runtime and throws an error.
Lazy Evaluation
Derivations are lazy – they only compute when accessed:
derive: {
expensiveCalculation: (facts) => {
// Only runs when someone reads the derivation
return heavyComputation(facts.data);
},
}
// Changing the fact just marks the derivation as stale
system.facts.data = largeDataset;
// Now it computes (and caches the result)
const result = system.derive.expensiveCalculation;
Caching
Results are cached until a dependency changes:
derive: {
filtered: (facts) => facts.items.filter(item => item.active),
}
// First access: computes and caches the result
const result1 = system.derive.filtered;
// Second access: returns cached value (no recomputation)
const result2 = system.derive.filtered;
// Changing a dependency marks the derivation as stale
system.facts.items = [...items, newItem];
// Next access: recomputes with updated data
const result3 = system.derive.filtered;
Conditional Dependencies
Derivations only track facts they actually access in a given run:
derive: {
display: (facts) => {
if (facts.showDetails) {
// Only tracked when showDetails is true
return facts.details;
}
return facts.summary;
},
}
When showDetails is false:
- Only
showDetailsandsummaryare tracked - Changes to
detailswon't trigger recomputation
When showDetails becomes true:
- Dependencies update to include
details summaryis no longer tracked
Complex Derivations
Array Operations
derive: {
// Filter to active items only
activeItems: (facts) => facts.items.filter(i => i.active),
// Sum all item prices
totalPrice: (facts) => facts.items.reduce((sum, i) => sum + i.price, 0),
// Sort by name (spread to avoid mutating the original)
sortedItems: (facts) => [...facts.items].sort((a, b) => a.name.localeCompare(b.name)),
}
Multiple Facts
derive: {
// Combine multiple facts into a boolean check
canCheckout: (facts) =>
facts.cart.length > 0 &&
facts.user !== null &&
facts.paymentMethod !== null,
}
With Composition
derive: {
// Base: filter active users
activeUsers: (facts) => facts.users.filter(u => u.active),
// Composed: filter admins from active users
activeAdmins: (facts, derived) =>
derived.activeUsers.filter(u => u.role === 'admin'),
// Composed: count from activeAdmins
activeAdminCount: (facts, derived) => derived.activeAdmins.length,
}
Type Inference
Derivation return types are inferred automatically:
derive: {
count: (facts) => facts.items.length, // number
names: (facts) => facts.users.map(u => u.name), // string[]
isReady: (facts) => facts.loaded && !facts.error, // boolean
}
// TypeScript infers the return types automatically
const count: number = system.derive.count;
const names: string[] = system.derive.names;
Best Practices
Keep Derivations Pure
Derivations should be pure functions with no side effects:
// Good – pure computation
fullName: (facts) => `${facts.firstName} ${facts.lastName}`
// Bad – side effect in a derivation
fullName: (facts) => {
console.log("Computing name"); // Don't do this
return `${facts.firstName} ${facts.lastName}`;
}
Use Effects for side effects instead.
Use Composition Over Duplication
Break complex derivations into smaller ones:
derive: {
// Good – composed, each piece is reusable
activeUsers: (facts) => facts.users.filter(u => u.active),
activeAdmins: (facts, derived) => derived.activeUsers.filter(u => u.admin),
// Not as good – duplicated filter logic
activeAdmins: (facts) => facts.users.filter(u => u.active && u.admin),
}
Avoid Expensive Work in Derivations
Derivations recompute whenever their dependencies change. For expensive operations, consider storing the result in a fact via an effect or resolver instead.
Runtime Registration
Derivations support dynamic registration and overrides:
system.derive.register("tripled", (facts) => facts.count * 3);
system.derive.assign("doubled", (facts) => facts.count * 20);
system.derive.unregister("tripled");
system.derive.call("doubled"); // recompute, ignoring cache
All four subsystems (constraints, effects, resolvers, derivations) share the same registration interface. See Runtime Dynamics for the full semantics table, introspection methods, and use cases.
Definition Meta
Derivations support an optional object form with metadata:
derive: {
// Function form (no meta) – unchanged
isReady: (facts) => facts.status === "ready",
// Object form with meta
displayName: {
compute: (facts) => `${facts.firstName} ${facts.lastName}`,
meta: { label: "Display Name", description: "Full name for UI" },
},
},
The compute key replaces the bare function. See Definition Meta for the full API.
Next Steps
- Facts – The source data for derivations
- Constraints – Use facts in rules (cross-module constraints can also read derivations via
crossModuleDeps) - Effects – Side effects that run after stabilization
- Events – Dispatch typed events to mutate facts

