Core API
•6 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(),
},
},
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
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
Derivations can depend on other derivations via the second parameter (derive):
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, derive) =>
`${derive.firstName[0] ?? ""}${derive.lastName[0] ?? ""}`.toUpperCase(),
// Composed – depends on firstName derivation
greeting: (facts, derive) =>
`Hello, ${derive.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, derive) =>
derive.activeUsers.filter(u => u.role === 'admin'),
// Composed: count from activeAdmins
activeAdminCount: (facts, derive) => derive.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, derive) => derive.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.
Next Steps
- Facts – The source data for derivations
- Constraints – Use facts in rules (constraints don't access derivations)
- Effects – Side effects that run after stabilization
- Events – Dispatch typed events to mutate facts

