Two years ago I wrote a blog post called "I Was Bored So I Brought Rust Enums to TypeScript: A Tale of Questionable Life Choices." It was exactly what it sounds like. The result worked, barely, and I said I'd never do it again.
I lied. But this time I actually finished it.
Aljabr is a TypeScript library for algebraic sum types: you define your variants once with union(), get typed constructors back, and consume them with match() that enforces exhaustiveness at compile time. Zero dependencies. The name is from the Arabic al-jabr (الجبر) — the word that gave us "algebra," meaning "reunion of broken parts." That felt right.
Here's where it starts:
import { union, match, Union } from "aljabr"
const Result = union({
Ok: (value: number) => ({ value }),
Err: (message: string) => ({ message }),
})
type Result = Union<typeof Result>
match(result, {
Ok: ({ value }) => value * 2,
Err: ({ message }) => { throw new Error(message) },
// Miss a variant? Compile error.
// Add a variant later and forget to update this? Also a compile error.
})
That's the pitch. Here's the substance.
The tag is a non-enumerable symbol on the prototype
This was the thing that killed my first attempt. The discriminant leaked into JSON.stringify, which broke everything downstream. The fix sounds obvious in retrospect but took some finessing to get right in TypeScript's type system.
The [tag] symbol lives on the prototype — not as an own property — and it's non-enumerable:
const ok = Result.Ok(42)
Object.keys(ok) // ["value"] — the tag? never heard of her
JSON.stringify(ok) // '{"value":42}' — clean
ok[tag] // "Ok" — accessible if you need it
This means you can spread, log, and serialize your variants without any surprises. They're just objects.
match() has two exhaustiveness modes
The first is strict: every variant gets a handler, no [__] allowed, no escape hatch. The type system won't let you compile until you've covered everything.
The second uses [__] as a catch-all for the rest:
import { __, getTag } from "aljabr"
// Exact — handle everything
match(shape, {
Circle: ({ radius }) => Math.PI * radius ** 2,
Rect: ({ w, h }) => w * h,
})
// Fallback — handle what you care about, catch the rest
match(event, {
Click: ({ x, y }) => `${x},${y}`,
[__]: (v) => `unhandled: ${getTag(v)}`,
})
The [__] handler gets the full variant value typed as the union, so you can still inspect or log it.
when() arms for sub-matching within a variant
Sometimes one handler per variant isn't expressive enough. when() lets you pattern match inside a variant — structural field matches, guard functions, predicate wrappers, or any combination:
import { when, pred, __ } from "aljabr"
const Msg = union({
Text: (body: string) => ({ body }),
Image: (url: string, alt: string) => ({ url, alt }),
})
type Msg = Union<typeof Msg>
const render = (msg: Msg): string =>
match(msg, {
Text: [
when({ body: pred((b) => b.length > 280) }, () => "<long post truncated>"),
when(__, ({ body }) => body),
],
Image: ({ url, alt }) => `<img src="${url}" alt="${alt}">`,
})
Arms are evaluated left to right, first match wins. pred() wraps a predicate for field-level matching — including type-narrowing predicates that carry the narrowed type through to the handler:
const Field = union({
Input: (value: string | number) => ({ value }),
})
match(field, {
Input: [
when(
{ value: pred((v): v is string => typeof v === "string") },
({ value }) => value.toUpperCase(), // value: string ✓
),
when(
{ value: pred((v): v is number => typeof v === "number") },
({ value }) => value.toFixed(2), // value: number ✓
),
when(__, () => "unknown"),
],
})
You can also combine a structural pattern and a guard function in a single arm — both have to pass:
when(
{ value: pred((v) => v.length > 0) }, // pattern: non-empty
(f) => f.retries === 0, // guard: first attempt
() => "fresh submit",
)
The runtime also tells you when you forget the when(__, ...) catch-all on an arm array that has guarded or pred arms. The error message literally says "add when(__, handler) as the last arm." I've debugged that mistake enough times that I felt it was worth the extra check.
Impl classes and Trait<R>() constraints
This is the feature I spent the most time on. You can mix shared behavior into every variant by passing impl classes to union():
import { union, Trait, getTag, Union } from "aljabr"
abstract class Auditable extends Trait<{ id: string }>() {
readonly createdAt = Date.now()
describe() {
return `[${getTag(this as any)} @ ${this.createdAt}] id=${(this as any).id}`
}
}
const Event = union([Auditable])({
Created: (id: string, name: string) => ({ id, name }),
Deleted: (id: string) => ({ id }),
})
type Event = Union<typeof Event>
const e = Event.Created("abc", "widget")
e.id // "abc"
e.name // "widget"
e.createdAt // number
e.describe() // "[Created @ 1712345678901] id=abc"
The interesting part is Trait<{ id: string }>(). That's a higher-order function returning an abstract class with a phantom type attached. It tells TypeScript: every variant factory in this union must return an object containing { id: string }. If one doesn't, the error surfaces on that specific variant, not a cryptic type mismatch on the union() call.
You can stack multiple impl classes, and their Trait<R> requirements are intersected:
abstract class HasId extends Trait<{ id: string }>() {}
abstract class HasLabel extends Trait<{ label: string }>() {}
const Node = union([HasId, HasLabel])({
Leaf: (id: string, label: string) => ({ id, label }),
Branch: (id: string, label: string, depth: number) => ({ id, label, depth }),
// Ghost: (n: number) => ({ n })
// ✗ compile error on Ghost: missing `id` and `label`
})
There's also a FactoryPayload<T> utility type that derives the plain payload type from a trait, so you're not repeating field annotations across factory functions.
Constant variants
Variants defined with a plain object (instead of a factory function) become no-arg constructors. Each call returns a fresh copy — no shared reference:
const Token = union({
EOF: { pos: -1 },
Newline: { char: "\n" },
Tab: { char: "\t" },
})
const a = Token.EOF()
const b = Token.EOF()
a === b // false — fresh objects every time
a.pos // -1
Useful for sentinel values or marker variants where you don't want accidental identity equality.
State machines fall out naturally
Once you have variants that are cheap to construct, exhaustive, and immutable by convention, state machine transitions basically write themselves:
const State = union({
Idle: { count: 0 },
Loading: (requestId: string) => ({ requestId }),
Success: (data: string[]) => ({ data }),
Error: (message: string, retries: number) => ({ message, retries }),
})
type State = Union<typeof State>
function transition(state: State, event: string): State {
return match(state, {
Idle: () => State.Loading(crypto.randomUUID()),
Loading: ({ requestId }) =>
event === "ok"
? State.Success(["item1", "item2"])
: State.Error("fetch failed", 0),
Error: ({ message, retries }) =>
retries < 3
? State.Loading(crypto.randomUUID())
: State.Error(message, retries),
Success: (s) => s,
})
}
Add a new state variant and every unhandled transition site is a compile error. It's the kind of thing that makes refactoring actually feel safe.
Generic variants with .typed() and Variant<Tag, Payload, Impl>
This is where it gets a bit more involved. By default, TypeScript instantiates generic type variables to unknown when inferring through ReturnType<F>, which means a naive Box.Wrap(42).value would be unknown. To preserve type parameters through factory definitions, there's an escape hatch:
import { Variant } from "aljabr"
const Option = union([]).typed({
Some: <T>(value: T) => ({ value } as Variant<"Some", { value: T }>),
None: () => ({ value: null } as Variant<"None", { value: null }>),
})
type Option<T> = typeof Option.Some extends (...args: any[]) => infer R ? R : never
| ReturnType<typeof Option.None>
Option.Some(42).value // number ✓
Option.Some("hi").value // string ✓
.typed() is a property on the builder returned by union([Impl]). It passes factory types through unchanged instead of mapping through Parameters<> / ReturnType<>, letting you write explicit generic signatures as the cast target.
The docs have a full Result<T, E> implementation using this — with a Thenable<T> impl class that gives you .then() chains typed as Result<R1, R2>, making the result both matchable and awaitable. That one took me a while to get right, and I'm unreasonably pleased with it.
The comparison to ts-pattern
ts-pattern is excellent. If you need structural matching over arbitrary objects you don't own, go use it. Aljabr is for a different contract: you own the union definition, you want the factory + mixin + match pipeline in one coherent API, and you want the discriminant to stay out of your serialized data.
| aljabr | ts-pattern | |
|---|---|---|
| Dispatch | Symbol tag on prototype | Structural inference |
| Tag in JSON | Invisible | Enumerable field |
| Shared variant behavior | Impl class mixins | Out of scope |
| Pattern breadth | Variant-scoped arms | Deep structural |
| Bundle | Zero deps | Small |
Status
Not yet on npm (that's the next thing). You can clone and build today:
git clone https://github.com/jasuperior/aljabr
cd aljabr && pnpm install && pnpm build
Repo: https://github.com/jasuperior/aljabr
Feedback welcome, especially on the .typed() / Variant<> DX — that's the newest part and I'd genuinely like to know if the ergonomics land.
Top comments (0)