API: Subpath exports
Subpath exports
Section titled “Subpath exports”phaze/dsl— terse DSL aliases (s,c,watch,phaze) with compile-time auto-thunkingphaze/store— opt-in deep reactive proxiesphaze/catch—<Catch>error boundaryphaze/portal—<Portal>for modals / tooltipsphaze/time—interval()/timeout()with reactive auto-cleanup (see below)phaze/match— equality predicates on signals (method formstep.is(val)/step.not(val)and free-function formis(step, val)/not(step, val)— see below)phaze/numeric— number-signal helpers (inc,dec,add,sub) — compile-time-stripped (see below)phaze/list— array-signal mutation helpers (remove,push,prepend,replace,patch) — compile-time-stripped (see below)phaze/defer— deferred hydration; the lowering target fordefer:idle/defer:visible/defer:media(see DSL ›defer:)phaze/dom— DOM primitives barrel (above)phaze/hydrate—hydrate()for adopting pre-rendered HTMLphaze/static—staticSubtree(html), the compile-emitted target forstaticSubtreeHoist(static subtrees skip per-element hydration)phaze/revalidate—withRevalidate(asyncSignal, seconds)for ISR-style periodic refresh ofsignal.asyncdataphaze/ssr-internal— internal SSR async-capture hooks used byrenderToStringAsync(not for app code)phaze/jsx-runtime,phaze/jsx-dev-runtime— automatic JSX transform targets
DSL & directives — phaze/dsl + JSX namespaces
Section titled “DSL & directives — phaze/dsl + JSX namespaces”The @madenowhere/phaze/dsl subpath ships short aliases (s, c, watch, phaze) that the build-time compiler auto-thunks, so c(count() * 2) reads as c(() => count() * 2). Three JSX namespaces — phaze:attr for reactive attributes, on:event for native events, use:directive for behavior attachments — round out the authoring surface.
import { s, c, watch, phaze } from '@madenowhere/phaze/dsl'import { autofocus, tooltip } from '@madenowhere/phaze-directives'
const name = s('')const greeting = c(name() ? `Hello, ${name()}!` : 'Type your name')
<input use:autofocus={true} use:tooltip={"Your name"} phaze:value={name()} on:input={(e) => name.set(e.currentTarget.value)}/><p>{phaze(`> ${greeting()}`)}</p>Every transform on this surface is compile-time only — no runtime cost over hand-written equivalents. See the DSL & directives page for the complete reference (vocabulary, namespace semantics, the directive contract, the canonical @madenowhere/phaze-directives package, and how to author your own).
phaze/time — timer helpers with reactive auto-cleanup
Section titled “phaze/time — timer helpers with reactive auto-cleanup”import { interval, timeout, hours, days } from '@madenowhere/phaze/time'import { inc } from '@madenowhere/phaze/numeric'
effect(() => { // Auto-cleared when this effect re-runs or disposes. // Two compile transforms cascade here: // 1. /time auto-thunks the second arg → interval(100, () => inc(count)) // 2. /numeric inlines inc(count) → interval(100, () => count.set(count() + 1)) // The /numeric import drops entirely at build time — zero shipped bytes. interval(100, inc(count))})The subpath exports two categories of helper: scope-aware timers (interval / timeout — the headline) and duration literals (seconds / minutes / hours / days / weeks / years / ms — covered in Duration helpers). Both ride the phaze/time import path. Importing only duration helpers ships zero bytes; importing interval or timeout brings the ~70 B phaze-time chunk.
Duration helpers
Section titled “Duration helpers”Human-readable seconds-domain literals that compile to numeric constants. Designed for cache TTLs (revalidate, Cache-Control, CDN headers) and other places where integer-seconds is the canonical unit.
import { seconds, minutes, hours, days, weeks, years, ms } from '@madenowhere/phaze/time'
seconds(10) // 10minutes(5) // 300hours(1) // 3600hours(1.5) // 5400days(3) // 259200weeks(2) // 1209600years(1) // 31536000 (365 days — matches HTTP convention)ms(500) // 500 (identity — documentary at setTimeout sites)Phaze-compile rewrites each call to its numeric literal at AST time, then prunes the specifier from the import declaration. Production bundles never reference the helper names — they ship as plain integers everywhere they’re called.
Canonical use — page-level cache directive in phaze-cloudflare:
import { hours, days } from '@madenowhere/phaze/time'
export const revalidate = { maxAge: hours(1), swr: days(7) }// compiles to: revalidate = { maxAge: 3600, swr: 604800 }Reads better at the call site than { maxAge: 3600, swr: 604800 } — and TypeScript narrows arguments to number, so a leftover seconds(60) after a copy-paste from a Cache-Control example still type-checks cleanly.
Unit choice — seconds for the family, ms as the timer-domain alias
Section titled “Unit choice — seconds for the family, ms as the timer-domain alias”| Helper | Returns | Use |
|---|---|---|
seconds(n) | n | Cache TTL, CDN max-age |
minutes(n) | n * 60 | "" |
hours(n) | n * 3600 | "" |
days(n) | n * 86400 | "" |
weeks(n) | n * 604800 | "" |
years(n) | n * 31536000 (365 days) | “Immutable” Cache-Control |
ms(n) | n (identity) | setTimeout, setInterval, interval()/timeout() |
The split: the seconds family matches HTTP/CDN convention — Cache-Control: max-age=N is integer seconds per RFC 9111, and every CDN edge spec follows. The ms helper is identity — it doesn’t transform the value, it documents the unit at the call site so reading interval(ms(500), fn) is unambiguous about what 500 means.
years(n) uses 365 days (31_536_000 seconds), not 365.25 — this matches the de-facto HTTP convention. Cache-Control: max-age=31536000 is the canonical “one year, immutable” value used by every major CDN doc, nginx/Apache defaults, and RFC 9111 examples. Internal consistency holds: years(1) and days(365) produce the same value.
When mixing units, the literal-arithmetic also folds at compile time:
interval(seconds(2) * 1000, fn)// → interval(2000, fn)Sub-millisecond resolution
Section titled “Sub-millisecond resolution”There’s no microseconds() or nanoseconds() helper, by design. JS doesn’t have a Number-typed API that consumes sub-ms values:
setTimeout(0.5, fn)is silently coerced to 0 (HTML spec enforces a 4ms floor anyway).Cache-Control: max-age=Nis integer seconds per RFC 9111 — sub-second TTL is unspecifiable.performance.now()returns fractional milliseconds; modern browsers clamp the precision to ~1ms for Spectre mitigation.- The only Number-typed nanosecond API is
process.hrtime.bigint()— and it returns BigInt, not Number. Workers don’t have it at all.
A helper that compiled microseconds(500) → 0.5 would suggest precision the platform can’t deliver. Better omitted.
Reactive delay
Section titled “Reactive delay”Both interval() and timeout() accept either a number or a thunk-of-number. With a thunk, the timer restarts when the thunk’s tracked deps change.
A signal IS a thunk — Signal<number> is structurally () => number — so you can pass the signal directly. No () => ms() wrapping, no surrounding effect(): interval() creates its own effect and registers its own cleanup, so the timer is already scope-aware on its own.
const ms = signal(1000)
// Restarts the interval every time `ms` changes; auto-cleared when the// surrounding component / module scope disposes.interval(ms, tick)For timeout, the reactive-delay form gives you a debounce shape — the countdown restarts every time a tracked signal in the delay thunk changes, so the body only fires once the signal has been stable for the full delay. The cleanest user-facing form is the 3-arg sugar timeout(restart, delay, fn), which phaze-compile rewrites at build time:
const draft = signal('')
effect(() => { timeout(draft, 1000, saveDraft(draft.current())) // │ │ │ // │ │ callback (auto-thunked) // │ delay in ms (static number) // restart trigger — `draft()` is called inside the wrapping thunk, // so its signal reads subscribe and the // countdown restarts on change.})What phaze-compile emits at build time:
timeout( () => { draft(); return 1000 }, // canonical 2-arg form's first arg () => saveDraft(draft.current()), // canonical 2-arg form's second arg)How React does it
Section titled “How React does it”React has no built-in interval primitive. The standard pattern is useEffect with manual cleanup:
useEffect(() => { const id = setInterval(() => setCount(c => c + 1), 1000) return () => clearInterval(id)}, [])Three known footguns:
- Stale closures. Functions captured by
setIntervalsee whatever state was current whenuseEffectran. To always read the latest value, you have to adduseRefand a seconduseEffectthat updates it. Dan Abramov’s classic “Making setInterval Declarative with React Hooks” exists because of this. - Forgetting the cleanup return. Easy to omit; the linter catches some cases but not all.
- Dependency-array gotchas. Anything the timer reads has to be in deps; missing one causes stale-closure bugs.
Did Preact improve on it?
Section titled “Did Preact improve on it?”No. Preact uses the same useEffect + manual cleanup pattern. @preact/signals doesn’t add a timer primitive. Same three footguns.
How Phaze innovates
Section titled “How Phaze innovates”Three real wins from the signals-native runtime:
-
No stale closures, ever. The callback reads signals via call (
count()), which always returns the current value. There’s nothing to capture incorrectly. NouseRefworkaround needed. -
No dep array. The surrounding effect tracks deps automatically — every signal you read inside it subscribes. The thunk-delay form makes “restart when this signal changes” a one-liner.
-
Auto-cleanup is structural, not opt-in.
interval()registers itself with the surrounding effect on creation. There’s no return-fn to remember; you literally cannot leak a timer that was created inside aneffect.
// Phaze // Reactimport { interval } from '@madenowhere/phaze/time' useEffect(() => { const savedCb = useRef()effect(() => { useEffect(() => { savedCb.current = callback }) interval(() => delay(), () => { const id = setInterval( fn(value()) () => savedCb.current(), }) delay}) ) return () => clearInterval(id) }, [delay]) // (the `useInterval` recipe — paraphrased)The “innovation” isn’t the timer functions themselves — it’s that the signals-native architecture erases all three React footguns for free. The wrapper just exposes that structurally.
Side-by-side
Section titled “Side-by-side”| React | Preact | Phaze | |
|---|---|---|---|
| Built-in interval primitive | none | none | interval() from phaze/time |
| Stale closures (callback sees old state) | yes — needs useRef workaround | yes — same as React | none — signals are call-time reads |
| Manual cleanup return | required | required | auto via scope |
| Dep-array boilerplate | required | required | none — no dep array exists |
| Reactive delay (restart on change) | manual useEffect + useRef choreography | same | interval(() => ms(), fn) |
| Debounce-shaped timeout | needs lodash or useRef recipes | same | timeout(sig, 300, fn) (3-arg sugar — phaze-compile rewrites to the canonical 2-arg form) |
Preact didn’t improve on React’s interval story — same useEffect + manual cleanup pattern, same three footguns. @preact/signals doesn’t add a timer primitive either.
Phaze’s innovation isn’t the timer functions themselves. It’s that the signals-native architecture eliminates all three footguns structurally:
- No stale closures — callbacks read signals via call (
count()); always current. - No dep array — Phaze tracks reactive deps automatically.
- No cleanup return to forget — auto-cleanup is part of registering the timer inside an effect.
The wrapper just exposes that. Zero footguns. Opt-in via phaze/time — apps that don’t import it pay nothing.
Bundle cost
Section titled “Bundle cost”| Brotli | |
|---|---|
Duration helpers (seconds/minutes/hours/days/weeks/years/ms) | 0 B — each call folds to a NumericLiteral at AST time; the specifier prunes from the import declaration. The phaze-time chunk isn’t emitted unless interval/timeout are also imported. |
interval / timeout (true-runtime helpers) | ~70 B (measured as phaze + phaze/time minus phaze (full) via the per-module size script — just the wrapper bodies; effect and cleanup are already in phaze (full)) |
Apps that don’t import /time | 0 B — tree-shaken via sideEffects: false |
Importing the duration helpers alongside interval/timeout doesn’t grow the phaze-time chunk. seconds(10), hours(1), etc. fold to literals and prune from the import declaration before Rollup runs — so a file with import { interval, hours } from 'phaze/time'; interval(...); hours(1) compiles to a post-pass shape identical to import { interval } from 'phaze/time'; interval(...); 3600. The chunk’s +70 B is paid for interval/timeout’s scope-aware machinery alone.
The “subpath alone” measurement (~0.53 KB) includes effect + cleanup transitively from phaze core. Those symbols already ship in any phaze-using app via @madenowhere/phaze, so the realistic marginal of adding /time is the ~70 B above.
For the canonical real-app number (your actual app’s phaze chunk + every other phaze chunk), add the sizeReport() plugin from @madenowhere/vite-plugin-phaze to your vite.plugins[]. It runs at the end of every vite build and emits the real Rollup-bundled chunk sizes — accurate, in-app, honest. The per-module numbers above are diagnostic approximations; sizeReport() is the source of truth for “what does my app actually ship”.
phaze/match — equality predicates on signals
Section titled “phaze/match — equality predicates on signals”Two import styles, pick the one that reads best at your call sites:
// Method style — augmented signal factory; methods on every signal it createsimport { signal } from '@madenowhere/phaze/match'
const step = signal<'email' | 'code' | 'done'>('email')step.is('email') // method — receiver-first ("step is email")step.not('email')// Free-function style — works with ANY signal (core, /dsl, computed, anything callable)import { signal } from '@madenowhere/phaze' // or /dslimport { is, not } from '@madenowhere/phaze/match'
const step = signal<'email' | 'code' | 'done'>('email')is(step, 'email') // predicate-firstnot(step, 'email')Both forms have identical tracking semantics: they call the signal’s read inside the caller’s tracking context (computed bodies, effects, the IIFE that phaze-compile wraps class: and bind: ops in), so the active subscription registers on every evaluation. Use .current() directly if you need an untracked comparison.
Which style when
Section titled “Which style when”| Use the method form when… | Use the free-function form when… |
|---|---|
You’re constructing the signal in the same file. const step = signal(...) lets step.is(...) flow naturally. | The signal came from elsewhere — computed(), a module-level signal imported across files, useAction’s reactive resources, anything you didn’t construct via /match. |
| You like receiver-first English-order reading: “step is email”. | You like the predicate-first reading common in functional code. |
You want autocomplete on the signal itself to surface .is / .not. | You want the import line to literally name the helpers you’re using. |
The two styles cost the same at runtime. The free functions can target any callable signal; the methods are scoped to signals created via /match’s factory. Mix freely across a codebase.
The idiomatic use case — union-typed state-machine signals
Section titled “The idiomatic use case — union-typed state-machine signals”// Method formconst step = signal<'email' | 'code' | 'done'>('email')<form class:hidden={step.not('email')} on:submit={requestCode}>…</form><form class:hidden={step.not('code')} on:submit={verifyCode}>…</form><div class:hidden={step.not('done')}>Access granted.</div>// Free-function formconst step = signal<'email' | 'code' | 'done'>('email')<form class:hidden={not(step, 'email')} on:submit={requestCode}>…</form><form class:hidden={not(step, 'code')} on:submit={verifyCode}>…</form><div class:hidden={not(step, 'done')}>Access granted.</div>Both read as: “hide this when step is not email.” See Primer › State in Phaze for the full pattern, why it’s safe (compile-time effect wrapping), and a comparison against userland helpers.
Why opt-in (rather than baked into core)
Section titled “Why opt-in (rather than baked into core)”Phaze’s runtime ships only what’s actually used. Plenty of phaze code never touches a union-typed signal — those projects shouldn’t pay bytes for .is/.not they never call. Same pattern as phaze/store, phaze/portal, phaze/catch, phaze/time, and the planned phaze/scheduler: one import line buys you the feature; not importing costs nothing.
Coexists with phaze/dsl
Section titled “Coexists with phaze/dsl”The DSL subpath (/dsl) ships compile-time-aware aliases (s, c, watch, phaze); the match subpath (/match) ships the predicate-method-augmented signal. Most apps that want both write:
import { c, watch, phaze } from '@madenowhere/phaze/dsl' // compile-time-aware aliasesimport { s } from '@madenowhere/phaze/match' // signals with .is / .nots from /match is just an alias for signal (same as s is an alias for signal in /dsl). Phaze-compile only rewrites identifiers traced from /dsl imports; s from /match runs the plain runtime factory — which is fine, because signal() creation doesn’t need compile-time auto-thunking. Functions called c / watch / phaze do need it, so import those from /dsl.
Bundle cost
Section titled “Bundle cost”| Brotli | |
|---|---|
| Marginal cost in a phaze-using app, compiler present (every call site rewritten) | 0 B — both the call sites AND the factory import drop entirely |
| Marginal cost in a phaze-using app, no compiler (runtime fallback ships) | ~60 B for the factory wrapper + free helpers (measured as phaze + phaze/match minus phaze (full)) |
Apps that don’t import /match | 0 B — tree-shaken via sideEffects: false |
sideEffects: false + tree-shaking + the compile-time rewrite mean importing /match is free in any real production build. The runtime fallback only ships when phaze-compile isn’t in the pipeline (vitest without the transform, ts-node REPLs, etc.), OR when phaze-compile detects an escape pattern for a factory-created signal (passed as a JSX prop, exported, destructured, aliased, method-as-value, or used as a function argument — all cases where the augmented .is/.not methods need to exist at runtime because they may be called from outside the file’s compile pass).
The “subpath alone” measurement (~1.35 KB) includes phaze-core transitive deps (signal, cleanup, etc.) that any phaze app already ships. The realistic marginal is the ~60 B above for the no-compiler fallback path.
What the compile transform does (phaze-compile):
// Sourceimport { s } from '@madenowhere/phaze/match'const step = s<'email' | 'code' | 'done'>('email')<form class:hidden={step.not('email')}>…</form>
// After phaze-compile (production output)import { s } from '@madenowhere/phaze/dsl' // ← swapped to /dsl — no augmentation neededconst step = s<'email' | 'code' | 'done'>('email')<form class:hidden={step() !== 'email'}>…</form> // ← method call inlinedTree-shaking applies — importing only { is, not } from /match drops the augmented factory from your bundle, and vice versa.
phaze/numeric — number-signal helpers (compile-time-stripped)
Section titled “phaze/numeric — number-signal helpers (compile-time-stripped)”Four type-narrowed helpers for Signal<number>. The phaze-compiler inlines each call at build time and drops the import declaration entirely — production bundles ship zero bytes from this subpath. The runtime bodies exist as a no-compiler fallback (vitest, ts-node, the TS playground); the precedent matches c / watch / phaze in /dsl.
import { inc, dec, add, sub } from '@madenowhere/phaze/numeric'import { s } from '@madenowhere/phaze/dsl'
const count = s(0)const fuel = s(50)
inc(count) // count.set(count() + 1)dec(count) // count.set(count() - 1)add(fuel, 5) // fuel.set(fuel() + 5)sub(fuel, 2) // fuel.set(fuel() - 2)The right-hand-side column is what phaze-compile emits at every call site. The import { inc, dec, add, sub } from '@madenowhere/phaze/numeric' declaration disappears in the compiled output once every binding’s references have been rewritten.
Type signatures
Section titled “Type signatures”inc: <T extends number>(sig: Signal<T>) => voiddec: <T extends number>(sig: Signal<T>) => voidadd: <T extends number>(sig: Signal<T>, n: number) => voidsub: <T extends number>(sig: Signal<T>, n: number) => voidT extends number lets the helpers work on narrowed number types (Signal<0 | 1>, Signal<-1 | 0 | 1>, etc.) — the as T cast inside each implementation preserves the type at the write site.
Combining with interval from phaze/time
Section titled “Combining with interval from phaze/time”import { interval } from '@madenowhere/phaze/time'import { inc } from '@madenowhere/phaze/numeric'import { s } from '@madenowhere/phaze/dsl'
const count = s(0)
interval(1000, inc(count))// delay │ tick callback (bare expression — no arrow needed)Two compile-time transforms compose:
intervalsecond-arg auto-thunk —interval(1000, inc(count))→interval(1000, () => inc(count)). Mirrors theon:eventCallExpression auto-thunk; only fires when the second arg is a bare expression. Already-arrow / Identifier / Function args pass through unchanged./numericinline rewrite — the innerinc(count)becomescount.set(count() + 1), and the/numericimport declaration drops out.
Final runtime: interval(1000, () => count.set(count() + 1)). Zero bytes ship from /numeric, no source-level () => ceremony.
When the compiler can’t rewrite
Section titled “When the compiler can’t rewrite”The compile-time rewrite is conservative — it only fires when the first argument is an Identifier. The two cases that bail and use the runtime fallback instead:
- Member-expression first arg:
inc(state.count)would expand tostate.count.set(state.count() + 1), duplicating the property access (potential side-effect double-trigger ifstate.countis a getter). The runtime fallback handles this safely. - The binding escapes as a value:
const handler = incorhandlers.bump = inc— the binding is captured by reference rather than invoked at a known call site. The compiler keeps the/numericimport with just the escaping specifier so the runtime can resolve it.
Both cases continue to work — they just pay the ~25-byte-per-helper runtime cost instead of getting the inline expansion.
Bundle cost
Section titled “Bundle cost”| Brotli | |
|---|---|
| Marginal cost in a phaze-using app, compiler present (every call site rewritten) | 0 B — import drops entirely |
| Marginal cost in a phaze-using app, no compiler (runtime fallback ships) | ~50 B for the four-helper runtime fallback (measured as phaze + phaze/numeric minus phaze (full)) |
Apps that don’t import /numeric | 0 B — tree-shaken via sideEffects: false |
sideEffects: false + tree-shaking + the compile-time rewrite mean importing /numeric is free in any real production build. The runtime fallback only ships when phaze-compile isn’t in the pipeline (vitest without the transform, ts-node REPLs, etc.).
The “subpath alone” size you’d see in the size report (~90 B brotli) includes a tiny amount of phaze-core symbols pulled in transitively for type definitions; the realistic marginal cost in an app that already uses @madenowhere/phaze is the ~50 B above.
phaze/list — array-signal mutation helpers (compile-time-stripped)
Section titled “phaze/list — array-signal mutation helpers (compile-time-stripped)”Six helpers for the common case where a signal’s value is an array and you want to mutate it without writing signal.update(prev => prev.<arr-method>(…)) at every call site. Same architectural shape as /numeric and /match: one subpath import, phaze-compile inlines each call to the canonical signal.set(...) form, the import declaration drops in production — zero shipped bytes when the compiler is in the pipeline.
import { s } from '@madenowhere/phaze/dsl'import { remove, push, prepend, replace, patch, matches } from '@madenowhere/phaze/list'
interface Todo { id: string; text: string; done: boolean }const todos = s<Todo[]>([])
remove(todos, { id }) // todos.set(todos().filter(_t => !(_t.id === id)))push(todos, newTodo) // todos.set([...todos(), newTodo])prepend(todos, newTodo) // todos.set([newTodo, ...todos()])replace(todos, { id }, updated) // todos.set(todos().map(_t => _t.id === id ? updated : _t))patch(todos, { id }, { done: true }) // todos.set(todos().map(_t => _t.id === id ? { ..._t, done: true } : _t))matches({ id }) // (_t) => _t.id === id (an inline arrow at the use site)The right-hand column is what phaze-compile emits at every call site. The import declaration disappears once every binding’s references have been rewritten.
matches is the predicate factory — it converts the same { key: value } shorthand the other helpers accept into a (item) => boolean predicate, for use with the native array methods that take a predicate: find, some, every, filter, findIndex, findLast, findLastIndex.
const item = todos().find(matches({ id })) // = todos().find(t => t.id === id)todos().some(matches({ done: true })) // any item done?todos().every(matches({ done: true })) // all done?todos().filter(matches({ kind: 'archived' })) // filter to subsetCompile output for matches({ id }) is a plain inline arrow (_t) => _t.id === id — same property-equality AND-chain machinery the mutation helpers use, exposed as a building block.
The match argument — predicate or partial-object
Section titled “The match argument — predicate or partial-object”remove, replace, and patch all take a match argument that accepts two shapes:
// Predicate form — `(item) => boolean`. The general case.remove(todos, (t) => t.priority > 5)
// Partial-object shorthand — `{ key: value, … }`. Reads as// "match where every listed property equals the given value."// Object-property shorthand `{ id }` makes the common case// (matching by a single id field) one expression.remove(todos, { id })remove(todos, { id, kind: 'archived' }) // multi-property ANDThe object-shorthand is the killer feature — it turns “remove the todo with this id” from the implementation-shaped prev => prev.filter(t => t.id !== id) into the intent-shaped remove(todos, { id }). The compiler emits the property-equality conjunction at the call site.
Type signatures
Section titled “Type signatures”type ListPredicate<T> = (item: T) => booleantype ListMatch<T> = ListPredicate<T> | Partial<T>
remove: <T>(sig: Signal<T[]>, match: ListMatch<T>) => voidpush: <T>(sig: Signal<T[]>, item: T) => voidprepend: <T>(sig: Signal<T[]>, item: T) => voidreplace: <T>(sig: Signal<T[]>, match: ListMatch<T>, newItem: T) => voidpatch: <T>(sig: Signal<T[]>, match: ListMatch<T>, partial: Partial<T>) => voidmatches: <T>(match: ListMatch<T>) => ListPredicate<T>Partial<T> narrows the object-shorthand so misspelled keys fail at typecheck.
What’s NOT in /list
Section titled “What’s NOT in /list”Deliberate omissions:
pop/shift— rarely useful in reactive code; the intent is usually “remove the item with property X = Y” (useremove)sort/reverse— these belong in acomputed(() => [...todos()].sort(…)), not as a signal mutation (sorting in place would lose the ability to derive other views)splice/map/forEach— too low-level;signal.update(transformer)is the right fall-through when no helper fits
If you need an op that isn’t here, signal.update(prev => …) is always available — /list is sugar over the common shapes, not a wrapper over every array method.
Coexistence with phaze/store
Section titled “Coexistence with phaze/store”/list’s helpers mutate the signal (produce a new array reference, fire the signal, consumers reconcile). [phaze/store](/reference/store/) mutates the proxy (per-property notify via proxy traps). They compose naturally:
import { s } from '@madenowhere/phaze/dsl'import { store } from '@madenowhere/phaze/store'import { remove } from '@madenowhere/phaze/list'
interface Todo { id: string; text: string; done: boolean }const todos = s<Todo[]>(initial.map(store))
// Array shape changes → use /list (outer signal fires, For reconciles)remove(todos, { id })
// Per-property changes → mutate the store directly (granular, no array re-create)const item = todos().find(t => t.id === id)if (item) item.done = !item.doneSee Components › For + per-item stores + closure handlers for the canonical TodoList composition.
Bundle cost
Section titled “Bundle cost”| Brotli | |
|---|---|
| Marginal cost in a phaze-using app, compiler present (every call site rewritten) | 0 B — import drops entirely |
| Marginal cost in a phaze-using app, no compiler (runtime fallback ships) | ~100 B for the five-helper runtime fallback (measured as phaze + phaze/list minus phaze (full)) |
Apps that don’t import /list | 0 B — tree-shaken via sideEffects: false |
sideEffects: false + tree-shaking + the compile-time rewrite mean importing /list is free in any real production build. The runtime fallback only ships when phaze-compile isn’t in the pipeline (vitest without the transform, ts-node REPLs, etc.), OR when a call site uses a non-literal match argument (dynamically-computed predicate) that the compiler can’t inline.