How algebraic data types make reactive state machines explicit, exhaustive, and type-safe
Reactive programming has a dirty secret: state is almost always a finite state machine in disguise, but nobody draws the diagram. You end up with a loading boolean here, a data field that might be null there, an error that coexists awkwardly with both. You write if (loading && !error && data !== null) and pray the compiler doesn't ask questions.
What if the compiler could enforce every possible state, and make the impossible ones unrepresentable?
That's the core idea behind aljabr: a TypeScript library that fuses algebraic data types, exhaustive pattern matching, and reactive signals into a single coherent design. The name is a transliteration of الجبر (algebra), literally "the reunion of broken parts." Which turns out to be a pretty good description of what it does to your application state.
The Problem with Primitive Reactive State
Every signal library gives you something like this:
const count = signal(0);
count.set(42);
count.get(); // 42
Simple enough. But signals have lifecycles: they start uninitialized, become active, and eventually get cleaned up. Flattening that into a single value box forces you to invent your own conventions: is null "not yet set" or "explicitly set to null"? Is reading a disposed signal an error or just zero?
These aren't hypothetical edge cases. They're the things that cause subtle bugs at 2 AM.
aljabr solves this by making the lifecycle an explicit algebraic data type:
type SignalState<T> = Unset | Active<T> | Disposed
Three variants. No overlap. No ambiguity. Every possible state of a reactive value — named, typed, and exhaustive.
Building Blocks: Unions and Pattern Matching
Before diving into signals, let's look at the foundation. aljabr's union function creates tagged variant factories:
import { union, match } from "aljabr";
const Shape = union({
Circle: (radius: number) => ({ radius }),
Rect: (w: number, h: number) => ({ w, h }),
});
type Shape = Union<typeof Shape>; // Circle instance | Rect instance
Each variant carries a hidden [tag] symbol on its prototype, invisible to Object.keys() and JSON.stringify(), but available for dispatch. The match function uses it to route exhaustively:
const area = match(shape, {
Circle: ({ radius }) => Math.PI * radius ** 2,
Rect: ({ w, h }) => w * h,
// TypeScript error if either arm is missing
});
This is exhaustiveness checking without a third-party library. Miss a variant, get a compile error.
Signal State as an ADT
With that foundation in place, look at how Signal<T> is actually designed:
// From src/prelude/signal.ts
export abstract class SignalLifecycle<T> extends Trait<{ value: unknown }> {
isActive(): boolean {
return match(this as unknown as SignalState<T>, {
Unset: () => false,
Active: () => true,
Disposed: () => false,
});
}
get(): T | null {
return match(this as unknown as SignalState<T>, {
Unset: () => null,
Active: ({ value }) => value,
Disposed: () => null,
});
}
}
export const SignalState = union([SignalLifecycle]).typed({
Unset: () => ({ value: null }) as Unset,
Active: <T>(value: T) => ({ value }) as Active<T>,
Disposed: () => ({ value: null }) as Disposed,
});
SignalLifecycle is a Trait, an abstract class that aljabr mixes into every variant at construction time. So Unset, Active, and Disposed all share the same isActive() and get() methods, and those methods are implemented via match internally. The state machine is the type.
Using a signal looks like this:
const count = Signal.create(0);
count.set(42);
count.get(); // 42 (tracked if inside a reactive context)
count.peek(); // 42 (always untracked)
match(count.state, {
Unset: () => "waiting for a value",
Active: ({ value }) => `current: ${value}`,
Disposed: () => "signal cleaned up",
});
No booleans. No null guards. The state is an ADT you can match on.
Custom State: Swapping the Lifecycle
Here's where it gets interesting. What if your reactive value isn't just "active or not", what if it carries domain-specific states like Unvalidated, Valid, or Invalid?
aljabr's SignalProtocol<S, T> lets you replace the built-in lifecycle with any union type you want:
import { Signal, Validation } from "aljabr/prelude";
const email = Signal.create(
Validation.Unvalidated<string, string>(),
{
extract: (state) => match(state, {
Unvalidated: () => null,
Valid: ({ value }) => value,
Invalid: () => null,
}),
}
);
email.set(Validation.Valid("ada@example.com"));
email.get(); // "ada@example.com"
email.read(); // Valid { value: "ada@example.com" } (tracked, full state)
set() now accepts a full Validation variant. get() extracts T | null via the protocol. read() returns the full state for when you need to match on Invalid errors inside a reactive context. The signal is no longer just a box, it's a typed state machine with domain-specific semantics.
This is the reunion of broken parts the name promises: your validation state and your reactive state, finally speaking the same language.
Effects as a State Machine
Async effects have the same problem as signals, amplified. An async operation can be idle, running, done with a value, done with an error, or stale after a dependency changed. That's five states. Libraries usually pick two or three and leave the rest as conventions.
aljabr models the whole thing:
// From src/prelude/effect.ts
export type Effect<T, E = never> =
| Idle<T, E> // thunk registered, not yet run
| Running<T, E> // in-flight promise
| Done<T, E> // completed: value or error
| Stale<T, E> // completed, but a dependency has since changed
Stale is the one most libraries quietly omit. It's the difference between "show a spinner" and "show the old value while the new one loads", the stale-while-revalidate pattern, baked directly into the type.
The Effect union carries a Computable trait that gives every variant chainable map, flatMap, and recover methods, implemented via match internally:
const fetchUser = Effect.Idle(async () => {
const res = await fetch("/api/user/1");
return res.json();
});
const fetchName = fetchUser
.map(user => user.name)
.recover(err => Effect.Idle(async () => "anonymous"));
const done = await fetchName.run();
// done is Done<string, never>
match(done, {
Done: ({ signal }) => match(signal, {
Active: ({ value }) => console.log("name:", value),
Disposed: () => console.log("request failed"),
Unset: () => {},
}),
});
Notice that Done carries a SignalState<T> for the result, not a raw value. Success and failure are encoded structurally, not as value | undefined with a separate error field.
Reactive Effects with watchEffect
Effect is a value you control manually. For fully automatic dependency tracking, aljabr provides watchEffect:
import { Signal, watchEffect, match } from "aljabr";
const userId = Signal.create(1);
const handle = watchEffect(
async () => {
const id = userId.get()!;
const res = await fetch(`/api/users/${id}`);
return res.json();
},
(result) => {
match(result, {
Done: ({ signal }) => render(signal.get()),
Stale: (stale) => {
renderStale(stale.signal.get()); // show old value
stale.run().then(done => render(done.signal.get()));
},
});
},
);
userId.set(2); // triggers onChange with Stale — caller decides when to re-run
handle.stop(); // unsubscribes all dependencies
Any Signal.get() or Signal.read() call inside the thunk is automatically tracked. When userId changes, the effect transitions to Stale and onChange fires, not with a vague "something changed" signal, but with the full Stale variant carrying the last known value.
Pass { eager: true } and the re-run happens automatically, delivering a fresh Done on every change.
Derived Values: Pull-Based Computation
For synchronous computed values, Derived<T> tracks dependencies lazily — re-evaluating only when read after a dependency has changed:
const firstName = Signal.create("ada");
const lastName = Signal.create("lovelace");
const fullName = Derived.create(
() => `${firstName.get()} ${lastName.get()}`
);
fullName.get(); // "ada lovelace" — computed on first read
lastName.set("byron");
fullName.get(); // "ada byron" — re-evaluated lazily
Its internal state is another ADT: Uncomputed | Computed<T> | Stale<T> | Disposed. When a dependency changes, the state transitions from Computed to Stale and dependents are notified, but the value isn't recalculated until someone asks. That's the pull-based part.
For async computation, AsyncDerived<T, E> adds Loading and Reloading to the mix, giving you stale-while-revalidate semantics for data fetching out of the box.
The Pattern That Runs Through Everything
Step back and notice what every primitive in aljabr has in common:
- A lifecycle modeled as a tagged union: explicit states, no null, no boolean flags
-
Trait mixins that attach shared behavior to every variant via
matchinternally - Exhaustive pattern matching at the consumer, so no state goes unhandled
This isn't just aesthetics. It means the TypeScript compiler becomes a collaborator. You can't accidentally read a Disposed signal as if it were Active, the types prevent it. You can't forget to handle the Invalid case in a validation-backed signal, match won't let you compile without it.
The reactive system and the type system are finally speaking the same language, because they're both built from the same algebra.
Get Started
aljabr is available on npm:
npm install aljabr
The core union, match, when, and pred primitives are the main entry point. The reactive layer — Signal, Derived, AsyncDerived, watchEffect, Effect, Result, Validation, Option — lives in the prelude:
import { union, match, when, __ } from "aljabr";
import { Signal, Derived, watchEffect } from "aljabr/prelude";
If you've been reaching for boolean flags to track state, or fighting TypeScript's type narrowing through chains of null checks, aljabr is worth an afternoon. State machines have always been there, it just gives them a name and a type.
All code snippets in this post are drawn directly from the aljabr source. Types like SignalState, Effect, DerivedState, and AsyncDerivedState are real exports, not pseudocode.
Top comments (0)