Change one thing and another thing changes. The second change follows because the system contains a relation between them: A → B. That arrow is the basis of reactivity.
The arrow may live in an event subscription, an invalidation rule, a dependency graph edge, or a callback. Wherever it lives, it creates type structure. It distinguishes one operation from another by what each can cause.
Operation Logs
Consider a cart operation log:
type CartOperation =
| { type: "item-added"; productId: string; quantity: number }
| { type: "item-removed"; productId: string }
| { type: "quantity-changed"; productId: string; quantity: number };
item-added describes a transition: Cart --AddItem→ Cart. But inside the system it means more than that. It induces other operations: AddItem → InventoryReservation, AddItem → PriceRecalculation, AddItem → AnalyticsRecord. Those consequences are part of what AddItem means.
The log records which operations occurred. The subscriptions record what those operations can cause. Together they define a schema of possible change.
A system with Open, Close, Lock, Unlock has a different type structure from one with Increment, Decrement, Reset. Both may serialize state as JSON. Both may use strings as operation names. Their runtime representation can be identical. But their arrows differ, and the arrows are what distinguish the types.
Keyed Caches
A global cache hides the same structure behind generic methods: get, set, invalidate. The application assigns meaning to keys:
["user", userId]
["user-posts", userId]
["product", productId]
["cart", cartId]
A write to one entry may make several others stale:
UserChanged → UserCacheUpdated → MemberListInvalidated → PermissionsRecomputed → UIUpdated
These relations may be encoded with tags, key prefixes, callbacks, refetch rules, or ordinary procedural code. Each rule establishes an arrow. The cache key gives the value an address. The invalidation and synchronization rules give it a place in the reactive type structure.
This is why manually synchronized caches become hard to reason about: their type system is scattered across the codebase. Every invalidate call makes a claim about causation. Every callback saying "when this changes, update that" adds another arrow. No single place records the full graph. The member list goes stale, and the failure surfaces as a bug report instead of an error.
Reactive DAGs
Fine-grained systems like SolidJS record causal relations in a dependency graph:
const [price, setPrice] = createSignal(10);
const [quantity, setQuantity] = createSignal(2);
const total = createMemo(() => price() * quantity());
The graph records that total reads price and quantity. Operationally: PriceChanged → TotalRecomputed. Another computation consumes total:
const formattedTotal = createMemo(() => formatCurrency(total()));
Now the chain runs: PriceChanged → TotalRecomputed → FormattedTotalRecomputed → DOMUpdated.
The important relation is not just Price × Quantity → Total. That describes a function. The reactive relation is ΔPrice → ΔTotal, ΔQuantity → ΔTotal. An operation on a source induces an operation downstream, and that downstream operation can become the source of another arrow.
The DAG records these induced operations. Its edges say that a change here may require work there. This is where the type structure lives.
A derived node becomes a distinct position in the graph because it has its own incoming and outgoing operations. Two nodes may both contain booleans—IsAuthorized and IsMenuOpen—but their causal neighborhoods differ. IsAuthorized → AvailableOperations. IsMenuOpen → VisibleMenu. The graph tells them apart by their arrows, the same work a type system does with names.
Dynamic Dependencies
SolidJS can change the graph while the program runs:
const value = createMemo(() => enabled() ? primary() : fallback());
When enabled() is true, PrimaryChanged induces ValueRecomputed. When false, FallbackChanged does. The predicate does more than choose a value: it changes which operations can induce recomputation downstream. The runtime discovers these relations by observing which signals are read. Once recorded, they become the active schema of causation. The graph is dynamic because the operational type structure is dynamic.
But this flexibility creates a problem. A static type system cannot see the active graph because it changes at runtime. The arrows are real, but they are not present in the source code. A compiler can check that total is a number. It cannot check that total will be recomputed when price changes, because the set of dependencies may shift. The type structure exists but is invisible to the tools we normally use to verify type structure.
Predicates as Type Boundaries
A predicate divides a space of values: P : A → Boolean. Reactive systems use predicates to change which operations are available.
const canCheckout = createMemo(() =>
cartItems().length > 0 && paymentMethod() !== null
);
The boolean marks a division in application state. In one region, SubmitOrder is invalid. In the other, it is available. The predicate creates two regions with different lawful arrows. The same pattern appears throughout: Anonymous → login allowed, Authenticated → logout allowed, EmptyCart → checkout unavailable, NonEmptyCart → checkout available. Each side of the line admits a different set of operations.
When the predicate changes, the operational structure changes with it. But here too the structure is implicit. The code does not declare that SubmitOrder becomes available when canCheckout is true. The availability is encoded in conditional rendering, guarded function calls, or disabled button states. The arrow from predicate to permitted operation is present in the behavior but not in any declaration.
One Structure, Different Machinery
An event log names operations and records them. A keyed cache embeds them in invalidation rules. A reactive DAG embeds them in dependency edges. The arrows live in different places, but the structure is the same: operation → induced operation → induced operation. That sequence is the reactive schema.
A schema of values might say that a user has an ID, name, and email. A reactive schema also says: UserNameChanged → ProfileChanged → SearchIndexChanged → RenderedNameChanged. It contains the possible states, the available operations, and the consequences of those operations.
The Problem
If the arrows are the type structure, they should be treated as type structure: declared, inspected, and checked. Most systems don't. The arrows exist, but they're spread across subscriptions, callbacks, and invalidation calls. Nothing can notice when an arrow is missing.
A stale value is a type error in this structure—a consequence the schema requires that the system failed to produce:
UserChanged → MemberListInvalidated (declared, implicitly)
UserChanged → ∅ (observed)
Some tools move in the right direction. Fine-grained runtimes discover arrows by tracking reads, so a dependency can't silently go missing. Query caches with hierarchical keys let one invalidation cover a family of entries. Incremental computation frameworks record every derivation and recompute exactly what a change reaches. Event-sourced systems replay logs through declared projections, making consequences enumerable rather than scattered.
But these are partial solutions. They capture arrows in one part of the system while the rest remains implicit. A SolidJS graph knows its signal dependencies but not that UserChanged should invalidate a member list. A cache invalidation rule knows that ["user", id] affects ["user-posts", id] but not that it should also affect a permission computation in a different module.
The deeper problem is that reactive arrows cross boundaries. They run from a database write through a cache layer through a UI framework to a DOM update. No single tool sees the whole path. The type structure is real but fragmented.
What would it mean to make it whole? Not just to track dependencies within a framework, but to declare and verify causal chains across the entire system. That would require a language or a set of conventions that can express arrows between subsystems written in different styles, stored in different places, maintained by different teams. It is a hard problem. It is also the problem that matters, because the bugs that cost the most are the ones where a change happens and something that should follow doesn't.
The causal graph of an application is not incidental wiring. It is the schema the application actually runs on. Like any schema, it works better declared than implied. But declaring it is not enough. It must be declared in a way that can be checked across the boundaries where the arrows actually run.













