poseui is a TypeScript UI toolkit for building type-safe, prop-driven HTML — without a virtual DOM, a component framework, or a browser runtime. It is designed for teams who want React-level type safety and composability on top of plain HTML strings, whether for server-side rendering, progressive enhancement, or lightweight client-side interactivity.
The toolkit is split into focused packages that compose cleanly. You can use only the parts you need.
The packages form a layered stack. Each layer is independently useful but designed to work with the others.
┌──────────────────────────────────────────┐│ poseui — typed HTML builder ││ html`` templates · presets · .handler() │└───────────────────┬──────────────────────┘ │ renders HTML strings ┌───────────┴───────────┐ │ │┌───────▼──────┐ ┌───────────▼──────────┐│ @poseui/on │ │ @poseui/store ││ DOM events │ │ reactive state │└───────┬──────┘ └───────────┬──────────┘ │ │┌───────▼──────────────────────▼──────────┐│ @poseui/form ││ schema-validated forms │└─────────────────────────────────────────┘@poseui/match — used internally by poseui's .when() and standalone
poseui is the core. Elements are constructed as composable, immutable builder chains. Each element is just a function: call it with props, get an HTML string back.
@poseui/on handles event binding. Rather than attaching listeners to element nodes, it registers listeners against CSS selectors and defers DOM querying to an explicit .mount() call. This means listeners survive innerHTML swaps — which is exactly how poseui's .handler() and render() cycle works.
@poseui/form adds schema-validated form handling on top of @poseui/on. It reads field values via FormData, runs them through any Standard Schema validator, and surfaces per-field errors as a flat map — without ever throwing.
@poseui/store provides reactive state. Its bind() method connects a store slice directly to a DOM element, re-rendering it whenever the selected slice changes. Pairs naturally with @poseui/form — the form extracts typed values, the store holds application state, and bind() keeps the UI in sync.
@poseui/match is a typed pattern matching utility used internally by poseui's .when() builder. It's also useful standalone for composing Tailwind class strings from a props object.
Close the builder into a mountable Component with .handler(). Wire DOM events using @poseui/on, and call render() to update the element without remounting listeners.
The html tagged template literal composes elements and raw values into larger HTML structures, with props threaded through automatically. PoseElements in an opening-tag position spread their classes and attributes into the host tag.
Add the tailwind4 preset for a fluent, type-safe API over Tailwind utilities. All static class names are registered in the instance's class registry and can be extracted at build time via getAllClasses() or the @poseui/extractor-unocss extractor.
import { createPose } from "poseui";import { tailwind4 } from "poseui/presets/tailwind4";const pose = createPose({ presets: [tailwind4] });const card = pose.as("div").flex().flex_col().gap(4).p(6).rounded("xl").shadow_md().bg("white");
Elements are functions. A PoseElement is just (props) => string. There is no framework, no renderer, no reconciler. Composition is function composition.
Immutable builders. Every method on a builder returns a new PoseElement. Intermediate elements can be stored and reused as base types for derived components.
Schema-first props. Props are validated at render time via Standard Schema. This means Zod, Valibot, ArkType, or any compatible library works out of the box — and schema defaults and transforms apply before any rendering code runs.
Selector-based events, not node references.@poseui/on binds listeners to CSS selectors against a scoped root, not to specific DOM nodes. This makes listeners survive innerHTML swaps, which is the mechanism behind poseui's render() function — re-render the HTML, keep the events.
Synchronous by default. All rendering, validation, form extraction, and state reads are synchronous. Async schemas are explicitly unsupported where synchronous behaviour is required.
Zero dependencies in each package. Every package in the poseui ecosystem carries zero runtime dependencies outside of alien-signals (in @poseui/store). Standard Schema is inlined as a type-only interface. There is no shared utility package that creates implicit coupling.
poseui is a zero-dependency, fully synchronous TypeScript library for building typed HTML elements as composable, prop-driven render functions. It is designed for server-side HTML generation, templating pipelines, and component-based UI construction without a virtual DOM or browser runtime.
Elements are built through a fluent builder API, validated at render time via any Standard Schema v1-compatible library (Zod, Valibot, ArkType, etc.), and rendered to plain HTML strings.
Every element starts from a Pose instance created with createPose(). Calling .as(tag) opens a builder chain that produces a PoseElement — a callable function that renders to an HTML string.
options.presets is an array of Preset objects that extend every element produced by this instance with additional methods (see Presets).
The instance exposes two methods:
pose.as(tag) — begins a builder chain for the given HTML tag. The tag name flows through as TTag, which constrains .attr() and .attrs() to valid attribute names and value types for that element.
pose.getAllClasses() — returns a deduplicated, space-separated string of all static class names registered by elements created from this instance. Useful as a virtual source file for Tailwind CLI or UnoCSS.
Binds a Standard Schema validator. After calling .input(), all subsequent methods receive props typed as the schema's output type. Schema defaults and transforms are applied on every render call.
If validation fails, a PoseValidationError is thrown. This error exposes a structured issues array matching the Standard Schema v1 FailureResult shape.
Sets a single HTML attribute. The attribute name is constrained to valid names for the element's tag, and the value type is inferred from the HTML attribute definition.
Sets multiple attributes at once. Accepts either a static record or a props function returning a record. Each key follows the same type constraints as .attr().
When used with createPose(), static class names from all .when() branches are eagerly registered in the class registry, so getAllClasses() captures them even if those branches are never rendered at build time.
Returns the resolved class string for the element without rendering a full HTML string. Useful for testing or integrating with external class inspection tools.
Component retains the call signature of the underlying PoseElement, so it can be nested as a child inside other elements or html\`templates without being independently mounted. A single.mount()` call on the outermost parent activates all handlers for the entire tree.
render() swaps el.innerHTML and re-runs schema validation without calling events.mount() again — event listeners bound to CSS selectors (as @poseui/on does) automatically apply to the newly rendered children.
Async schemas are not supported in .mount(). Resolve async schemas before calling mount.
A PoseElement — rendered with the template's current props
A function (props) => string | null | undefined — called with current props
A static string, number, null, or undefined
Opening-tag position spread — when a PoseElement appears between a tag name and >, its class string and attributes are merged into the host tag rather than rendering as a child element:
Adds fluent methods for every Tailwind v4 utility class. Methods use underscores where Tailwind uses hyphens, since hyphens are not valid in JavaScript identifiers.
import { createPose } from "poseui";import { tailwind4 } from "poseui/presets/tailwind4";const pose = createPose({ presets: [tailwind4] });const card = pose.as("div").flex().flex_col().gap(4).p(6).rounded("xl").shadow_md().bg("white");
Methods fall into four categories based on how they generate class names:
Static — zero-argument methods that emit a fixed class: .flex() → "flex", .hidden() → "hidden"
Prefix — single-argument methods that append a value: .px(4) → "px-4", .grid_cols(3) → "grid-cols-3"
Raw — single-argument methods where the argument is used directly as the suffix: .bg("indigo-600") → "bg-indigo-600"
Optional — zero or one argument: .rounded() → "rounded", .rounded("xl") → "rounded-xl"
All argument-accepting methods also accept a dynamic function (props) => value, enabling prop-driven utility classes:
A typed adapter over basecoat.css, a CSS component library. Each method maps directly to basecoat class names and handles the class derivation logic internally.
@poseui/extractor-unocss provides a UnoCSS extractor that statically analyses source files for poseui method call chains and emits the Tailwind class names they would produce at runtime — without executing the code.
Dynamic arguments (arrow functions, variable references) are skipped since their values are unknowable at static analysis time. For dynamic classes, pose.getAllClasses() should be used as a complementary mechanism.
All attribute names and values are type-checked against a comprehensive HTML attribute map. The tag name TTag is carried through the entire builder chain, so .attr() and .attrs() reject invalid attributes at compile time:
pose.as("input").attr("type", "email"); // ✓ validpose.as("input").attr("type", "emal"); // ✗ TypeScript error — invalid type valuepose.as("button").attr("href", "/home"); // ✗ TypeScript error — href not valid on buttonpose.as("a").attr("href", "/home"); // ✓ valid
data-* and aria-* attributes are always accepted on any element with string values. The full ARIA attribute map with typed value unions is included in the attribute definitions.
IDL normalisation is applied throughout — attribute names use their HTML content attribute form, not their JavaScript property names: for not htmlFor, readonly not readOnly, tabindex not tabIndex, colspan not colSpan.
When a PoseElement is created from a createPose() instance, every static class string passed to .cls() or accumulated through preset methods is registered in the instance's class registry. This enables a build-time CSS pipeline:
const pose = createPose({ presets: [tailwind4] });// Define your components...const card = pose.as("div").rounded("xl").shadow_md().p(6).bg("white");const button = pose.as("button").btn();// ...// Extract all static classes for Tailwind/UnoCSS:const allClasses = pose.getAllClasses();// → "rounded-xl shadow-md p-6 bg-white ..."// Write to a virtual file for Tailwind CLI content scanning:fs.writeFileSync("poseui-classes.txt", allClasses);
Dynamic classes (those produced by functions over props) are not included in the registry, since their values are unknowable at build time. The UnoCSS extractor handles these through static analysis.
@poseui/on is a zero-dependency, framework-agnostic TypeScript library for typed DOM event registration. It provides a deferred, selector-based event binding model where listeners are registered before the DOM is queried, and all actual element lookup and attachment is deferred to an explicit .mount() call. Cleanup is handled by the unmount function returned from .mount().
It integrates directly with poseui's .handler() API as the EventMap implementation, but works equally well as a standalone utility against any DOM.
import { createEventMap } from "@poseui/on";const events = createEventMap();events.target<HTMLButtonElement>("#submit").on("click", (e) => { e.currentTarget.disabled = true; // e.currentTarget is HTMLButtonElement});events.target<HTMLInputElement>(".search").on("input", (e) => console.log(e.currentTarget.value));events .targets<HTMLTableRowElement>("tbody tr") .on("mouseenter", (e) => e.currentTarget.classList.add("highlighted"));const unmount = events.mount(document.querySelector("#app"));// Later, on teardown or navigation:unmount();
The key design properties are:
Deferred DOM access — selectors are registered eagerly, but querySelector/querySelectorAll runs only when .mount() is called. Elements do not need to exist at registration time.
Scoped mounting — .mount(root) only queries within the provided root element, preventing cross-component interference.
Precise cleanup — the unmount function removes exactly the listeners that were attached during its corresponding .mount() call, without affecting other active mount instances.
Full type safety — e.currentTarget is typed to the element type passed as the generic parameter, eliminating manual casts.
Creates an isolated event registration instance. Each instance maintains its own internal registry — there is no global state or shared side effects between instances.
const eventsA = createEventMap();const eventsB = createEventMap();// eventsA and eventsB are completely independent
Registers a single-element target by CSS selector. When .mount() is called, this resolves via querySelector — matching the first element in the mount root that satisfies the selector.
The type parameter T constrains e.currentTarget in all listeners registered on the returned handle. It defaults to Element if omitted.
Registers a multi-element target by CSS selector. When .mount() is called, this resolves via querySelectorAll — attaching the registered listeners to every matching element within the mount root.
events.targets<HTMLLIElement>(".item").on("click", (e) => { // e.currentTarget is the specific <li> that was clicked e.currentTarget.classList.toggle("selected");});
Each matched element receives its own independent listener attachment. e.currentTarget inside the handler refers to the individual element that triggered the event, not the selector or the collection.
If the selector matches nothing, no listeners are attached and no error is thrown.
Queries all registered selectors within root, attaches every accumulated listener, and returns a cleanup function that removes them all.
root defaults to document if not provided, making all selectors global. Passing a specific element scopes all queries to that subtree, preventing matches against elements outside the component boundary.
.mount() can be called multiple times on the same EventMap instance — for example when a component is re-used in multiple subtrees. Each call returns an independent cleanup function that only removes the listeners attached during that specific call.
const unmountA = events.mount(rootA);const unmountB = events.mount(rootB);unmountA(); // removes listeners in rootA only — rootB unaffected
DOM querying is deferred entirely to this point. Registering a target for a selector that does not yet exist in the DOM is safe — as long as the element exists by the time .mount() is called, it will be found.
// Register before the element existsevents.target<HTMLButtonElement>("#late-btn").on("click", handler);// Create the elementconst root = document.createElement("div");root.innerHTML = `<button id="late-btn">Click</button>`;// Mount after — #late-btn is found correctlyevents.mount(root);
Registers an event listener on this target. type is constrained to valid event names for the element type T: HTMLElementEventMap keys for HTMLElement subtypes, SVGElementEventMap keys for SVGElement subtypes, and ElementEventMap keys otherwise.
The listener receives a typed event object where currentTarget is narrowed to T:
Removes a previously registered listener before .mount() is called. The listener reference must be the same function reference that was passed to .on().
const handle = events.target<HTMLButtonElement>("#btn");handle.on("click", handler);handle.off("click", handler); // handler will not be attached at mount time
Calling .off() for a listener that was never registered does nothing and does not throw. Removing one listener leaves others on the same target unaffected:
handle.on("click", handlerA);handle.on("click", handlerB);handle.off("click", handlerA);// Only handlerB will be attached at mount time
The function returned from .mount() removes every listener that was attached during that specific mount call. It is safe to call multiple times — subsequent calls are no-ops:
const unmount = events.mount(root);unmount(); // removes all listenersunmount(); // no-op, no error
Cleanup is precise and scoped to the mount instance. Calling unmount() does not affect listeners attached by other .mount() calls on the same EventMap, or listeners managed by any other EventMap instance:
const events = createEventMap();events.target<HTMLButtonElement>(".btn").on("click", handler);const unmountA = events.mount(rootA);const unmountB = events.mount(rootB);unmountA(); // only rootA listeners removed// rootB listeners remain active
Selector scoping. Listeners are only attached to elements found within the mount root. An element with the same selector outside the root is never matched:
// <button class="btn"> exists both inside and outside rootevents.target<HTMLButtonElement>(".btn").on("click", handler);events.mount(root); // only the button inside root receives the listener
SVG support. The generic type parameter accepts any Element subtype, including SVG elements. Event types are narrowed accordingly to SVGElementEventMap:
Identical listener deduplication. The native addEventListener specification silently ignores duplicate registrations of the same (element, type, listener) triple. Registering the same function reference twice via .on() results in only one active listener:
handle.on("click", handler).on("click", handler);// addEventListener deduplicates — handler fires once per click
No re-querying on re-render. When used with poseui's .handler() and render(), events.mount() is called once at initial mount. Subsequent render() calls swap innerHTML without calling events.mount() again. Because listeners are bound to CSS selectors rather than specific element node references, they automatically apply to the new children written by render().
@poseui/form is a zero-dependency TypeScript library for typed form binding via Standard Schema v1. It wires a schema (Zod, Valibot, ArkType, or any compatible library) to an HTML form element, handling validation, per-field error state, dirty tracking, and programmatic submission. Event registration and teardown are managed via @poseui/on.
The library is fully synchronous and never throws on validation failures — errors are always returned as structured data.
If a selector string is provided, the element is looked up at .mount() time, not at createForm() time. If no element is found when .mount() is called, an error is thrown.
schema — StandardSchemaV1
Any Standard Schema v1-compatible validator. Form field values are read via FormData and passed to the schema's validate function. Schema defaults and transforms are applied before onSubmit is called.
schema: z.object({ code: z.string().trim().toUpperCase(), age: z.coerce.number().min(0),});// Field value " abc " reaches onSubmit as "ABC"// Field value "25" (string) reaches onSubmit as 25 (number)
Async schemas are not supported. If the schema's validate function returns a Promise, validation is treated as a failure and a warning is logged to the console.
Called with the structured issue list when validation fails on submission. If omitted, failed submissions are silently ignored (other than updating .errors()).
Controls when schema validation runs automatically in response to field changes:
"submit" — validation runs only when the form is submitted. Errors do not appear until the user attempts to submit.
"change" — validation runs on change events, which fire when a field loses focus with a changed value. Errors appear field-by-field as the user moves between fields.
"input" — validation runs on every input event, updating errors on every keystroke.
Dirty tracking (.isDirty()) is always active via change events regardless of this setting.
root — Element | Document(optional)
Scopes all event registration to a specific subtree. Passed directly to @poseui/on's .mount(). Defaults to document. Use this to prevent the form binding from matching elements outside a specific component boundary.
Can be called at any time — before mount, between events, or in response to external signals — making it suitable for driving derived UI state reactively without waiting for a submission.
Returns the per-field error state from the most recent validation attempt. Keys are dot-separated field paths (e.g. "user.email" for a nested schema), and values are arrays of error message strings so multiple failing rules on a single field are all surfaced.
form.errors();// → { "email": ["Invalid email"], "age": ["Must be at least 18"] }
Returns an empty object before any validation has run. Errors are cleared automatically after a successful submission.
The returned object is a shallow copy — mutating it does not affect the form's internal error state.
Returns true if any field value has changed since .mount() was called. Dirty state is tracked via change events on all input, select, and textarea elements within the form, regardless of the validateOn setting.
form.isDirty(); // → false (before any interaction)// User edits a field and moves focus away:form.isDirty(); // → true
Programmatically triggers the validate → onSubmit / onError cycle without requiring a user gesture. Useful for submit buttons outside the <form> element, or for testing.
When the form is mounted, submit() dispatches a SubmitEvent on the form element so any other listeners registered on it also fire. When called before mount, it runs the validation cycle directly.
form.mount();form.submit(); // dispatches submit event → triggers onSubmit or onError
Attaches event listeners to the form and activates the binding. Returns a cleanup function equivalent to calling form.unmount().
Throws if the target element cannot be found in the DOM at mount time.
const unmount = form.mount();// later:unmount();
Safe to call multiple times — each call produces an independent cleanup. The cleanup function from each call removes only the listeners attached during that specific mount.
Validation issues are converted to a flat FormErrors map before being stored and returned from .errors(). Issue paths are serialised to dot-separated strings:
Each createForm() call produces an independent instance with its own internal state and event registration. Multiple forms can be mounted simultaneously without interfering with each other:
Async schemas are not supported.@poseui/form is fully synchronous. If the schema's validate function returns a Promise, the result is treated as a failure and a warning is logged. With Zod, this means avoiding async .refine() callbacks.
event.preventDefault() is always called. The submit event listener always calls preventDefault(), preventing the native browser form submission regardless of whether validation passes or fails.
Forms without an id. When an HTMLFormElement reference is passed as target and the element has no id attribute, a temporary data-poseui-form attribute is set on the element to construct a CSS selector for @poseui/on. This attribute is removed when .unmount() is called.
textarea and select are fully supported. All three standard field element types (input, select, textarea) are included in both dirty tracking and live validation listeners. textarea content and select values are read correctly via FormData.
Root scoping prevents cross-component interference. Passing a root element confines all querySelector/querySelectorAll calls to that subtree, so forms nested inside a bounded component container cannot accidentally match or be affected by forms elsewhere in the document.
@poseui/store is a reactive state management library backed by alien-signals. Its API mirrors zustand/vanilla — getState, setState, subscribe, and getInitialState — making it immediately familiar to anyone coming from that ecosystem. One addition, bind(), closes the loop between state changes and DOM renders by connecting a store slice directly to an element's innerHTML via a poseui render function.
// Single-call form — suitable for state without actionsfunction createStore<T extends object>(creator: StateCreator<T>): StoreApi<T>;// Curried form — required when state includes actionsfunction createStore<T extends object>(): (creator: StateCreator<T>) => StoreApi<T>;
Creates a reactive store. The creator function receives set, get, and the api object, and returns the initial state.
Use the curried form when state includes action functions. TypeScript cannot infer T when the creator both produces and references the type in the same call — the curried form fixes T first, then takes the creator:
// ✓ Curried — T is explicit, inference works for actionsconst store = createStore<{ count: number; inc: () => void }>()((set) => ({ count: 0, inc: () => set((s) => ({ count: s.count + 1 })),}));// ✓ Single-call — fine when state is plain data with no self-referencing actionsconst store = createStore(() => ({ count: 0, name: "Ada" }));
The creator receives three arguments:
set(update) — merges a partial update into state (see .setState())
get() — returns the current state snapshot synchronously
api — the full StoreApi<T> object, enabling actions to reference api.getInitialState() for reset patterns
Merges a partial update shallowly into the current state. Accepts either a plain object or an updater function that receives the current state and returns a partial.
The selector form uses alien-signals' computed internally — the listener is skipped entirely when a setState changes other keys but leaves the selected value at the same reference. This makes it efficient for subscribing to individual slices of a larger state object:
const store = createStore(() => ({ count: 0, name: "Ada" }));store.subscribe((s) => s.count, listener);store.setState({ name: "Grace" }); // listener not called — count unchangedstore.setState({ count: 1 }); // listener called
Both forms:
Do not fire immediately on subscription — the listener is called only on subsequent state changes
Return an unsubscribe function; calling it stops further notifications
Are idempotent when the returned unsubscribe is called multiple times
Multiple independent subscriptions can coexist on the same store. Unsubscribing one does not affect others.
Connects a DOM element's innerHTML to store state via a render function. Renders immediately on call, then re-renders automatically whenever the relevant state changes. Returns an unsubscribe / cleanup function.
The selector form is the preferred approach when the store holds multiple independent slices. It avoids unnecessary re-renders when unrelated parts of state change:
const store = createStore(() => ({ count: 0, name: "Ada" }));store.bind( document.getElementById("count-display")!, (s) => s.count, (count) => `<span>${count}</span>`,);store.setState({ name: "Grace" }); // count-display does not re-renderstore.setState({ count: 1 }); // count-display re-renders
The cleanup function stops all re-renders for that binding:
const unsub = store.bind( el, (s) => s.count, (c) => `${c}`,);store.setState({ count: 1 }); // el updates to "1"unsub();store.setState({ count: 99 }); // el stays at "1"
Re-exported from alien-signals. Groups multiple subscriptions and bindings so they can all be torn down with a single stop() call. Useful for component-level cleanup where many bindings need to be removed together.
import { createStore, effectScope } from "@poseui/store";const stop = effectScope(() => { store.bind( document.getElementById("count")!, (s) => s.count, (count) => `Count: ${count}`, ); store.bind( document.getElementById("name")!, (s) => s.name, (name) => name, ); store.subscribe((state, prev) => { if (state.count !== prev.count) analytics.track("count_changed"); });});// Later — tears down all three at once:stop();
@poseui/store pairs naturally with @poseui/form — the form drives validation and extracts typed values, while the store holds application state that changes in response. The store's .subscribe() (selector form) or .bind() then updates the DOM reactively:
Reactive engine.@poseui/store is backed by alien-signals' fine-grained reactivity. computed is used internally to derive selector slices, so the dependency graph is tracked automatically — only effects that depend on a changed signal re-run.
Shallow merge, not deep merge.setState performs a one-level { ...current, ...patch } merge. Nested objects must be replaced entirely when changed:
// ✗ Does not update nested.value — replaces only the top-level keystore.setState({ nested: { value: 99 } });// ✓ Spread to preserve sibling keysstore.setState((s) => ({ nested: { ...s.nested, value: 99 } }));
Slice comparison uses Object.is. The selector form of both .subscribe() and .bind() compares slices by reference using Object.is. Primitive values (numbers, strings, booleans) are compared by value. Object and array slices are compared by identity — a new object reference, even with identical contents, is treated as a changed value.
Subscriptions do not fire immediately. Neither form of .subscribe() calls the listener at registration time. Both fire only on subsequent state changes. .bind() does render immediately on call, since the element needs to display initial state.
Multiple binds on the same element. Binding two separate store slices to the same element results in both effects writing el.innerHTML independently. Whichever effect runs last on any given state change wins. Prefer a single bind with a render function that reads all required data, or use two separate elements.
@poseui/match is a zero-dependency, framework-agnostic TypeScript utility for typed pattern matching against plain objects. It is designed to compose class strings conditionally from props — and is used internally by poseui's .when() builder method — but works equally well for any scenario where multiple conditions need to be evaluated against a single value to produce results.
The library exposes a single match() function that accepts a plain object and returns an immutable MatchBuilder. Matchers are registered by chaining .when() calls, and results are collected with one of four terminal methods: .all(), .first(), .last(), or .resolve().
The default output type TOut is string, making class string composition require no type annotations. An explicit TOut type parameter can be provided when producing other value types.
Creates a MatchBuilder against value. The builder is immutable — every .when() call returns a new builder instance without modifying the one it was called on.
const base = match({ x: 1 });const withMatcher = base.when(({ x }) => x === 1, "hit");base.all(); // → [] (original is unaffected)withMatcher.all(); // → ["hit"]
Switches on the value of a specific key. Cases are Partial — an unmatched value simply contributes nothing. Like the predicate form, results can be static values or functions receiving the full input.
match({ variant: "primary" }) .when("variant", { primary: "bg-indigo-600 text-white", secondary: "bg-slate-200 text-slate-900", }) .first();// → "bg-indigo-600 text-white"match({ variant: "ghost" as string }) .when("variant", { primary: "bg-indigo-600", secondary: "bg-slate-200" }) .all();// → [] (no match for "ghost")
Result functions in a key switch receive the full input value, not just the matched key's value:
Returns the first matched result, or undefined if nothing matched. Useful when matchers are mutually exclusive and only the highest-priority match is needed.
When TOut is string (the default), joins all matched results with a single space and returns a string. Returns an empty string if nothing matched — no leading or trailing spaces are added.
When TOut is not string, behaves identically to .all() and returns TOut[].
Evaluation is lazy. Matchers are not evaluated until a terminal method is called. The builder itself holds only the registered matcher descriptors.
Numeric keys work. JavaScript coerces numeric object keys to strings in property lookups, and the key switch form accounts for this:
match({ level: 3 as number }) .when("level", { 1: "text-sm", 2: "text-base", 3: "text-lg" }) .first();// → "text-lg"
Boolean values require the predicate form. Booleans are not valid PropertyKey types in TypeScript, so a boolean prop cannot be used as a key switch key. Use a predicate instead:
// ✓ correctmatch({ active: true }) .when(({ active }) => active, "ring-2") .when(({ active }) => !active, "opacity-50") .first();// ✗ not possible — active: boolean is not a valid key switch keymatch({ active: true }).when("active", { true: "ring-2" });
Deeply nested values are accessible via result functions. The predicate and result function always receive the full input object, including nested fields: