Change one thing and another thing changes.
The second change follows because the system contains a relation between them:
A → B
An operation on A induces an operation on B. That arrow is the basis of reactivity.
The type doesn't need to appear as an interface, enum, class, or schema declaration. It may be embedded in an event subscription, an invalidation rule, a synchronization function, a predicate, or an edge in a dependency graph.
The relation itself creates type structure: it distinguishes one operation from another by what each can cause. A reactive system is a network of those distinctions.
Operation logs
An operation log makes the structure visible.
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 possible transition:
Cart --AddItem→ Cart
It may also induce other operations:
AddItem → InventoryReservation
AddItem → PriceRecalculation
AddItem → AnalyticsRecord
AddItem → CartRender
Those consequences are part of what AddItem means inside the system. Inventory treats it as a reason to reserve stock, pricing as a reason to recompute totals, analytics as something to record.
The original operation expands into a chain:
AddItem
→ PriceRecalculation
→ DiscountApplied
→ TotalChanged
→ CheckoutEligibilityChanged
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 operations such as:
Open
Close
Lock
Unlock
has a different type structure from one with:
Increment
Decrement
Reset
Both may serialize their 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.
cache.get(key);
cache.set(key, value);
cache.invalidate(key);
The application assigns meaning to the 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
Those relations may be encoded with tags, key prefixes, callbacks, refetch rules, or ordinary procedural code. Each rule establishes an arrow:
UserRecord → PermissionState
UserRecord → SearchResults
UserRecord → OrganizationMemberList
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.
Synchronization depends on the same structure. A counter update can be combined by adding a delta. A set insertion can be combined by merging members. A document needs an edit model. A bank transfer must preserve ledger invariants.
CounterUpdate → add delta
SetInsert → merge member
DocumentPatch → apply edit
BankTransfer → preserve invariant
The storage layer may treat all four as data. What separates them is how their operations behave.
Reactive DAGs
Fine-grained systems such as SolidJS record causal relations in a dependency graph.
const [price, setPrice] = createSignal(10);
const [quantity, setQuantity] = createSignal(2);
const total = createMemo(() => {
return price() * quantity();
});
The graph records that total reads price and quantity. Operationally:
PriceChanged → TotalRecomputed
QuantityChanged → TotalRecomputed
Another computation may consume total:
const formattedTotal = createMemo(() => {
return formatCurrency(total());
});
Now the graph contains:
PriceChanged
→ TotalRecomputed
→ FormattedTotalRecomputed
→ DOMUpdated
The runtime stores the local dependencies and reevaluates the affected path when a source changes.
The important relation is not only:
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:
ΔTotal → ΔFormattedTotal
ΔFormattedTotal → ΔDOM
The DAG records these induced operations. Its edges say that a change here may require work there. This is where the type structure lives.
The graph creates type positions
A derived node becomes a distinct position in the graph because it has its own incoming and outgoing operations.
const isEmpty = createMemo(() => count() === 0);
const buttonState = createMemo(() => {
return isEmpty() ? "disabled" : "enabled";
});
The graph contains:
CountChanged
→ IsEmptyChanged
→ ButtonStateChanged
→ RenderedButtonChanged
IsEmpty, ButtonState, and RenderedButton may never be declared as named types, but the system still treats them as different kinds of things. Different operations produce them, different operations consume them, and different consequences follow when they change. A node is partly defined by what can change it and what it can change.
Two nodes may both contain booleans:
IsAuthorized
IsMenuOpen
Their stored representation is the same. Their causal neighborhoods are not.
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(() => {
return enabled() ? primary() : fallback();
});
When enabled() is true:
EnabledChanged → ValueRecomputed
PrimaryChanged → ValueRecomputed
When it is false:
EnabledChanged → ValueRecomputed
FallbackChanged → ValueRecomputed
The active set of arrows changes. The predicate does more than choose a value: it changes which operations can induce recomputation downstream.
The runtime discovers those 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.
Predicates draw type boundaries
A predicate divides a space of values.
P : A → Boolean
The values for which P holds form a refinement:
{x : A | P(x)}
Reactive systems use predicates to change which operations are available.
const canCheckout = createMemo(() => {
return (
cartItems().length > 0 &&
paymentMethod() !== null
);
});
The boolean marks a division in application state:
CheckoutDisabledState
CheckoutEnabledState
In one state, SubmitOrder is invalid. In the other, it is available.
CheckoutDisabledState → no SubmitOrder
CheckoutEnabledState → SubmitOrder permitted
The predicate creates two regions with different lawful arrows. The same pattern appears throughout application code:
Anonymous → login allowed
Authenticated → logout allowed
Disconnected → connect allowed
Connected → send allowed
EmptyCart → checkout unavailable
NonEmptyCart → checkout available
Each side of the line admits a different set of operations, which is the same distinction a type makes.
A predicate may activate an effect, switch dependencies, expose a command, render a component, or refine a value. When it changes, the operational structure changes with it.
PredicateChanged
→ ActiveDependenciesChanged
→ AvailableOperationsChanged
Types are operational
A type is often described as a set of values. In a reactive system, its operations and consequences carry more information than that set alone.
A cart admits cart operations:
add item
remove item
change quantity
A connection admits connection operations:
connect
disconnect
send
fail
A request moves through a different set of transitions:
Idle → Loading → Loaded
Idle → Loading → Failed
The lawful arrows define the thing.
Two values may have the same runtime shape while belonging to different types:
type Dollars = number;
type Celsius = number;
Both are numbers. A price change may propagate through totals, tax, and rendering:
PriceChanged
→ TotalChanged
→ TaxChanged
→ DisplayChanged
A temperature change may propagate through warnings and fan control:
TemperatureChanged
→ WarningStateChanged
→ FanSpeedChanged
The machine representation doesn't tell us which is which. The causal structure does.
One structure, different machinery
An event log names the operations and records them:
Operation → SubscriberOperation
A keyed cache embeds them in invalidation and synchronization rules:
CacheWrite → Invalidation
Invalidation → Refetch
Refetch → CacheWrite
A reactive DAG embeds them in dependency edges:
SignalWrite → Computation
Computation → DerivedNode
DerivedNode → Effect
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 DAG is one representation of that schema. The event log is another. The cache and its invalidation rules are another.
Arrows
The language of morphisms fits because reactive relations compose.
f : A → B
g : B → C
Together:
g ∘ f : A → C
In a reactive system:
change A
→ recompute B
→ update C
The relevant mapping often acts on operations:
ΔA → ΔB
A write induces a recomputation, invalidation, event, or effect.
An edge in a DAG is not a programming-language type declaration. It records a lawful causal mapping, and the network of those mappings constitutes the operational type structure of the system.
A value changing by itself is mutation:
A changes
Reactivity begins when that operation has a defined consequence:
A changes → B changes
That arrow may be named in an event system, handwritten in cache logic, or discovered by a runtime. Wherever it lives, it identifies a domain, a consequence, and a repeatable relation between them.
What follows
If the arrows are the type structure, they should be treated the way type structure is treated: declared, inspected, and checked.
Most systems don't do this. The arrows exist, but they're spread across subscriptions, callbacks, and invalidation calls. No single place records that:
UserChanged → MemberListInvalidated
So nothing can notice when the arrow is missing. The member list goes stale, and the failure surfaces as a bug report instead of an error.
A stale value is a type error in this structure — a consequence the schema requires that the system failed to produce:
UserChanged → MemberListInvalidated (declared)
UserChanged → ∅ (observed)
Some tools already move in this direction. A fine-grained runtime discovers the arrows by tracking reads, so a dependency can't silently go missing. Query caches with hierarchical keys let one invalidation statement cover a family of entries:
invalidate(["user", userId])
→ ["user-posts", userId]
→ ["user-comments", userId]
Incremental computation frameworks record every derivation and recompute exactly what a change reaches. Event-sourced systems replay the log through declared projections, so the consequences of an operation are enumerable rather than scattered.
Each of these takes arrows that would otherwise live in imperative code and moves them into a structure something can read.
That is the practical content of the claim. The causal graph of an application is not incidental wiring. It is the schema the application actually runs on — and like any schema, it works better declared than implied.
All reactivity is schema based.
All reactivity is type based.
Top comments (0)