DEV Community

Cover image for Signals, Effects, and the Algebra Between Them
Ja
Ja

Posted on

Signals, Effects, and the Algebra Between Them

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:    () => {},
    }),
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 match internally
  • 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
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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.

Source and docs on GitHub →


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)