Skip to content

Spread bindPath(source, path, options?) into a form control to wire it to a field nested inside a signal-held record. Sibling to bind() and bindField() — same discriminator surface — but reaches into deep object paths that those primitives don’t.

The realistic case: a multi-step user-profile form on a Signal<User> where fields live at user.profile.email, user.prefs.dark, etc.

import { bindPath } from "@whisq/core/forms";
interface User {
id: string;
profile: { name: string; email: string; age: number };
prefs: { dark: boolean };
}
const user = signal<User>({
id: "u1",
profile: { name: "", email: "", age: 0 },
prefs: { dark: false },
});
form(
input({ ...bindPath(user, ["profile", "name"]) }),
input({ type: "email", ...bindPath(user, ["profile", "email"]) }),
input({ type: "number", ...bindPath(user, ["profile", "age"], { as: "number" }) }),
input({ type: "checkbox", ...bindPath(user, ["prefs", "dark"], { as: "checkbox" }) }),
)

bindPath() lives on the @whisq/core/forms sub-path so apps that only need bind() and bindField() (the 80% case) pay no bundle cost. See /api/imports/ for the full sub-path reference.

import { bindPath } from "@whisq/core/forms";
// Typed overloads for depths 1–4 (TypeScript checks each path key against the
// parent shape). Default-discriminator returns TextBind only when the field
// resolves to `string`; misuse on a non-string field is a type error.
function bindPath<T, K1 extends keyof T>(
source: Signal<T>,
path: readonly [K1],
): T[K1] extends string ? TextBind : never;
function bindPath<T, K1 extends keyof T, K2 extends keyof T[K1]>(
source: Signal<T>,
path: readonly [K1, K2],
): T[K1][K2] extends string ? TextBind : never;
// ... and so on for depth 3 and 4 (same shape, more keys).
// Loose signature — works at any depth, less TS inference.
function bindPath<T>(
source: Signal<T>,
path: readonly PropertyKey[],
options: { as: "number" },
): NumberBind;
function bindPath<T>(
source: Signal<T>,
path: readonly PropertyKey[],
options: { as: "checkbox" },
): CheckboxBind;
function bindPath<T, V extends string>(
source: Signal<T>,
path: readonly PropertyKey[],
options: { as: "radio"; value: V },
): RadioBind<V>;
function bindPath<T>(
source: Signal<T>,
path: readonly PropertyKey[],
options?: PathOptions,
): TextBind;
ParamTypeDescription
sourceSignal<T>The signal holding the root record
pathreadonly PropertyKey[]A non-empty array of keys describing the field’s path. Object keys only — no array indexes.
options.as"number" | "checkbox" | "radio" (optional)Kind of input — same discriminator as bind() and bindField()
options.valueV (radio only, required)The per-radio value, same as bind()

A plain props object matching the input kind — identical to bind()’s return shapes:

KindReturn typeProps
defaultTextBind{ value: () => string, oninput: (e) => void }
numberNumberBind{ value: () => string, oninput: (e) => void } (coerces)
checkboxCheckboxBind{ checked: () => boolean, onchange: (e) => void }
radioRadioBind<V>{ value: V, checked: () => boolean, onchange: (e) => void }

Profile form (text + email + number + checkbox)

Section titled “Profile form (text + email + number + checkbox)”
import { signal, form, input, label } from "@whisq/core";
import { bindPath } from "@whisq/core/forms";
interface User {
id: string;
profile: { name: string; email: string; age: number };
prefs: { dark: boolean };
}
const user = signal<User>({
id: "u1",
profile: { name: "", email: "", age: 0 },
prefs: { dark: false },
});
form(
label("Name", input({ ...bindPath(user, ["profile", "name"]) })),
label("Email", input({ type: "email", ...bindPath(user, ["profile", "email"]) })),
label("Age", input({ type: "number", ...bindPath(user, ["profile", "age"], { as: "number" }) })),
label("Dark mode", input({ type: "checkbox", ...bindPath(user, ["prefs", "dark"], { as: "checkbox" }) })),
)
interface Settings {
billing: { plan: "free" | "pro" | "team" };
}
const settings = signal<Settings>({ billing: { plan: "free" } });
div(
(["free", "pro", "team"] as const).map((plan) =>
label(
input({
type: "radio",
name: "plan",
...bindPath(settings, ["billing", "plan"], { as: "radio", value: plan }),
}),
plan,
),
),
)

Composing with bindField() for arrays inside the path

Section titled “Composing with bindField() for arrays inside the path”

bindPath does not traverse arrays — ["items", 0, "done"] would walk items[0] as if 0 were an object key on whatever the array exposes. And bindPath requires a Signal<T> (a single record), not a Signal<T[]> — calling bindPath(orders, [...]) on an array signal is a TypeError at the path-walk level.

For per-item fields inside an array, use bindField() at the array level. For nested-object fields on a single record, use bindPath at that record. To combine both — a CRUD grid where each row is a record with nested fields — factor the per-row render into its own component that owns a per-row Signal<T>:

import { signal, component, div, input, each } from "@whisq/core";
import { bindField } from "@whisq/core";
import { bindPath } from "@whisq/core/forms";
interface Order {
id: string;
items: Array<{ id: string; sku: string; qty: number }>;
shipping: { address: { line1: string; city: string } };
}
const orders = signal<Order[]>([/* … */]);
// Per-row component with its own Signal<Order> derived from the parent.
// Two-way sync back to the parent array isn't free — wire an effect or
// expose an `onChange(updated)` prop. This snippet shows the binding
// shape only; pick a sync strategy for your store.
const OrderRow = component((props: { initial: Order }) => {
const order = signal(props.initial);
return div(
// bindField — per-cell input on the row's nested items array.
each(() => order.value.items, (item) =>
input({
type: "number",
...bindField(
// Bind against a derived Signal<items[]>; in practice you
// expose a sub-signal or use a setter helper here.
signal(order.value.items),
item,
"qty",
{ as: "number" },
),
}),
{ key: (i) => i.id },
),
// bindPath — nested-object field on the row's single record.
input({ ...bindPath(order, ["shipping", "address", "city"]) }),
);
});

For most apps a simpler split works: keep deeply nested fields out of array elements, or use one Signal<Record> per top-level entity instead of one Signal<Record[]>. bindPath is sharpest when paired with single-record state — settings panels, profile forms, single-document editors.

  • Structural sharing on writes. Writes produce a new root and new objects at every level on the path; sibling branches preserve referential identity. Downstream computed / effect that depend on unaffected branches don’t re-run.
  • Missing intermediates. Reading through a missing intermediate returns undefined. Writing creates the object structure as needed — bindPath(empty, ["a", "b", "c"]) writes through { a: { b: { c: value } } }. If an intermediate is null, undefined, or any non-object value (string, number, boolean), the write replaces it with a fresh object. Intentional: lets you wire forms before optional records exist, but be aware that writing into a path whose ancestor is currently a primitive will clobber that primitive.
  • Object keys only. Array traversal is not supported in the path. ["items", 0] would treat 0 as an object key, not an array index — usually not what you want. Use bindField() for array-element fields. This keeps bindPath predictable and its implementation small.
  • Typed overloads for depths 1–4. TypeScript checks each path key against the parent shape, giving you autocomplete + misuse detection through 4 levels. Deeper paths fall through to the loose signature (same runtime, less TS inference) — split into a sub-record signal if you find yourself nesting further.
  • TypeError throws. bindPath: source must be a Signal if you pass an unwrapped value (e.g. bindPath(user.value, …) instead of bindPath(user, …)). bindPath: path must be a non-empty array of keys if path is empty or not an array.
ShapeHelperWhen
Single signal you ownbind(signal)One input ↔ one signal
Field inside an item inside a signal-held arraybindField(source, item, key)Per-row inputs in lists, CRUD grids, editable tables
Field at a nested object path inside a signal-held recordbindPath(source, path)Multi-step forms, settings panels, user.profile.email shapes

The three helpers compose: a CRUD grid where each row is a record with nested fields uses bindField at the array level and (after factoring the row into a component with a per-row signal) bindPath for the inner-object cells.

See also: Reactive shapes cheat sheet, Forms guide → binding into nested records, Nested Item Editing guide — when an array row hosts a nested record, the guide’s pattern 3 covers the row-as-component split. Persisted settings recipebindPath + persistedSignal<Settings> for nested-field writes on a persisted single record. Handler collision dev warning — applies to bindPath() spreads the same way as bind().

Docs current to v0.1.0-alpha.9 . All releases →