Typed pattern matching utility for TypeScript. Zero dependencies. Framework agnostic.
npm install quando
# or Bun
bun add quandoquando exports five complementary utilities:
| Export | Purpose |
|---|---|
match(obj) |
Chain-style pattern matcher against plain objects — great for composing class strings or deriving values from props |
when(value, ...) |
Lightweight truthy branch helper — returns null on no-match, safe for JSX/template interpolation |
collect(...values) |
Merges match() and when() results into a single space-separated string, filtering all falsy values |
each(items) |
Svelte-style {#each} list helper — map items to output with an optional empty fallback |
resource(envelope) |
Tri-state branch helper for async derived values (loading / error / ready) |
Build a typed matcher against a plain object. Chain .when() calls to register matchers, then evaluate with .resolve(), .all(), .first(), or .last().
The default TOut is string, making it ergonomic for Tailwind / CSS-in-JS class composition without any type annotation.
import { match } from "quando";
const classes = match({ disabled: true })
.when(({ disabled }) => disabled, "opacity-50 cursor-not-allowed")
.resolve();
// → "opacity-50 cursor-not-allowed"const classes = match({ variant: "primary" })
.when("variant", {
primary: "bg-indigo-600 text-white",
secondary: "bg-slate-200 text-slate-900",
})
.resolve();
// → "bg-indigo-600 text-white"const classes = match({ variant: "primary", size: "lg", disabled: true })
.when("variant", {
primary: "bg-indigo-600 text-white",
secondary: "bg-slate-200 text-slate-900",
})
.when("size", {
sm: "px-2 py-1 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
})
.when(({ disabled }) => disabled, "opacity-50 cursor-not-allowed")
.resolve();
// → "bg-indigo-600 text-white px-6 py-3 text-lg opacity-50 cursor-not-allowed"import type { ReactNode } from "react";
const icon = match<typeof props, ReactNode>({ status: "error" })
.when("status", {
ok: <CheckIcon />,
error: <XIcon />,
pending: <SpinnerIcon />,
})
.first();Every case value can be a static result or a function that receives the full input — useful for derived values:
match({ size: "lg", scale: 4 })
.when("size", {
sm: ({ scale }) => `gap-${scale / 2}`,
lg: ({ scale }) => `gap-${scale * 2}`,
})
.first();
// → "gap-8"| Method | Returns | Description |
|---|---|---|
.resolve() |
string | TOut[] |
Joins all matched strings with a space; returns array for non-string TOut |
.all() |
TOut[] |
All matched results in registration order |
.first() |
TOut | null |
First matched result — null when nothing matched (Ilha / JSX safe) |
.last() |
TOut | null |
Last matched result — useful for override patterns |
Immutability — each
.when()call returns a new builder. The original is never mutated.
Pass snapshots into match() — read island state inside the object, not the accessor itself:
// ✅ snapshot fields
match({ variant: props.variant, count: state.todos().length })
.when(({ count }) => count === 0, () => html`<p>No todos</p>`)
.when("variant", { primary: () => html`<Badge />` })
.first();
// ❌ accessors in the match object — match compares by value, not reactive paths
match({ todos: state.todos })Button classes (pairs with Areia data-variant / collect):
<Button
data-variant={collect(
match({ active: isActive("/") })
.when(({ active }) => active, "secondary")
.resolve(),
"ghost",
)}
>
Home
</Button>Rules of thumb
| Goal | Use |
|---|---|
| Space-joined class string | .resolve() or collect(match(...).resolve(), …) |
| One optional UI branch | .first() (returns null on no match) |
| All matching branches | .all() |
| Override / last wins | .last() |
Use .first() / .last() for JSX (like when()), not .resolve() — empty .resolve() is "", which is for strings.
A truthy branch helper that returns null on no-match instead of false — because false renders as text in JSX/ilha templates while null is silently ignored.
Accepts any value — null, undefined, "", 0, and false are treated as no-match (same as if (value)). The true-branch callback receives the narrowed, truthy value.
Both branch callbacks receive the condition value as their argument. This keeps the API consistent and allows the callback to reference it without an outer closure.
import { when } from "quando";
when(state.result(), (result) => <p>{result}</p>)
// → <p>…</p> or null
when(user.isPremium, () => <PremiumBadge />)
// → <PremiumBadge /> or null
when(isActive, () => "ring-2 ring-indigo-500")
// → "ring-2 ring-indigo-500" or nullwhen(
isOnline,
() => "text-green-600",
() => "text-red-500",
);
// → "text-green-600" or "text-red-500"Both branches are lazy — the thunk for the untaken branch is never called.
The true and false branches may return different types:
when(
flag,
() => "active",
() => 0,
);
// → string | number<div class="card">
{when(user.isPremium, () => (
<PremiumBadge />
))}
{when(
count > 0,
() => (
<Counter value={count} />
),
() => (
<EmptyState />
),
)}
</div>Merges any number of string values into a single space-separated string, filtering out all falsy values (null, undefined, false, "").
Designed to compose match() and when() results in vanilla TS templates without reaching for an external utility like clsx.
import { collect } from "quando";
collect("px-4", null, "font-bold", undefined, "text-white");
// → "px-4 font-bold text-white"
collect(null, false, undefined, "");
// → ""const classes = collect(
match(props)
.when("variant", { primary: "bg-indigo-600 text-white", secondary: "bg-slate-200" })
.when("size", { sm: "px-2 py-1", md: "px-4 py-2", lg: "px-6 py-3" })
.resolve(),
when(props.disabled, () => "opacity-50 cursor-not-allowed"),
when(props.active, () => "ring-2 ring-offset-2 ring-indigo-500"),
);
// → "bg-indigo-600 text-white px-6 py-3 opacity-50 cursor-not-allowed"For JSX projects that already use
clsxorcva,collect()is optional — those libraries own the same space.collect()shines in vanilla TS templates where no such utility is present.
Svelte-style {#each} / {:else} for mapping collections to rendered output.
.as() returns a mapped array directly — empty collections render as []. Chain .else() only when you need an empty-state fallback.
Both branches are lazy — only the taken branch runs.
import { each } from "quando";
each([1, 2, 3]).as((n) => n * 2);
// → [2, 4, 6]
each(["a", "b"]).as((s, i) => `${i}:${s}`);
// → ["0:a", "1:b"]
each([]).as((n) => n * 2);
// → []Use directly in ilha templates — arrays interpolate as concatenated children:
html`<ul>${each(items).as((item) => html`<li>${item.name}</li>`)}</ul>`each(items)
.as((item) => html`<li>${item.name}</li>`)
.else(() => html`<p>No items</p>`);
each(items)
.as((item) => html`<li>${item.name}</li>`)
.else(html`<p>No items</p>`);
// → RawHtml[] or RawHtmlWhen the collection is empty, .else() returns the fallback wrapped in a single-element array (unless the fallback is null or false) — a static value or the result of the callback (which receives the empty array). The map function is not called.
Use .key() when rendering reorderable lists — the key is passed as the third argument to .as():
each(items)
.key((item) => item.id)
.as((item, index, id) => Row.key(id)({ item }))
.else(() => html`<EmptyState />`);| Call | Returns | Empty collection |
|---|---|---|
.as(fn) |
TOut[] |
[] |
.as(fn).else(fn | value) |
TOut[] | TEmpty |
fallback value or callback |
Tri-state branch helper for async derived envelopes — matches ilha's DerivedValue<T> shape ({ loading, value, error }).
Branch order is loading → error → ready. Only the taken branch runs.
import { each, resource } from "quando";
resource(derived.users)
.loading(() => html`<Spinner />`)
.error((e) => html`<p>${e.message}</p>`)
.ready((users) =>
each(users ?? [])
.key((u) => u.id)
.as((u, _i, id) => Row.key(id)({ user: u }))
.else(() => html`<EmptyState />`),
);Skip branches you don't need:
// error + ready only (no loading UI)
resource(derived.data)
.error((e) => html`<Error message=${e.message} />`)
.ready((data) => render(data));
// ready only
resource(derived.count).ready((n) => html`<span>${n ?? 0}</span>`);When loading is true and no .loading() branch is registered, execution falls through to .ready() with value: undefined.
Combining the full API in an island .render():
.render(({ derived, input }) =>
html`<ul class="${collect(
"list",
when(input.compact, () => "list-compact"),
)}">
${resource(derived.items)
.loading(() => html`<li class="loading">Loading…</li>`)
.error((e) => html`<li class="error">${e.message}</li>`)
.ready((items) =>
each(items ?? [])
.key((item) => item.id)
.as((item, _i, id) => Item.key(id)({ item }))
.else(() => html`<li class="empty">Nothing here</li>`),
)}
</ul>`,
)// Object pattern matching
match<TIn, TOut = string>(value: TIn): MatchBuilder<TIn, TOut>
// Truthy branching (null on no-match — Ilha / JSX safe)
when<T, R>(condition: T, onTrue: (value: NonFalsy<T>) => R): R | null
when<T, R, F>(condition: T, onTrue: (value: NonFalsy<T>) => R, onFalse: (value: T) => F): R | F
// MatchBuilder: .when(pred|key) → .resolve() | .all() | .first() | .last()
// .first() / .last() return null when nothing matches
// String merging
collect(...values: (string | null | undefined | false)[]): string
// List rendering
each<TItem>(items: readonly TItem[]): EachBuilder<TItem>
// EachBuilder: .key(fn) → .as(fn) → .else(fn)?
// .as(fn) → .else(fn)?
// .as() returns EachResult<TItem, TOut> (TOut[] with optional .else())
// Async derived tri-state
resource<T>(envelope: ResourceEnvelope<T>): ResourceBuilder<T>
// ResourceEnvelope: { loading: boolean; value: T | undefined; error: Error | undefined }
// ResourceBuilder: .loading(fn) → .error(fn) → .ready(fn)
// .error(fn) → .ready(fn)
// .ready(fn)- Zero dependencies — ships nothing but TypeScript source.
- Immutable builders —
.when()always returns a new builder; safe to share and reuse intermediate chains. - Lazy evaluation — result functions and thunks are only called when their branch is taken.
nullnotfalse—when()andmatch().first()/.last()follow JSX conventions so no-match renders are silent.- Value threading —
when()passes the condition into both branch callbacks, keeping logic self-contained without outer closures. - Composable by design — all exports are independent but built to work together.
- Framework agnostic — works equally in React, Preact, Solid, Svelte, Ilha, or plain TS.
- Svelte-familiar control flow —
each().as().else()mirrors{#each}/{:else};resource()handles async derived envelopes.
MIT