bindPath()
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" }) }),)Import
Section titled “Import”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";Signature
Section titled “Signature”// 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;Parameters
Section titled “Parameters”| Param | Type | Description |
|---|---|---|
source | Signal<T> | The signal holding the root record |
path | readonly 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.value | V (radio only, required) | The per-radio value, same as bind() |
Returns
Section titled “Returns”A plain props object matching the input kind — identical to bind()’s return shapes:
| Kind | Return type | Props |
|---|---|---|
| default | TextBind | { value: () => string, oninput: (e) => void } |
number | NumberBind | { value: () => string, oninput: (e) => void } (coerces) |
checkbox | CheckboxBind | { checked: () => boolean, onchange: (e) => void } |
radio | RadioBind<V> | { value: V, checked: () => boolean, onchange: (e) => void } |
Examples
Section titled “Examples”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" }) })),)Settings with a radio group
Section titled “Settings with a radio group”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.
Semantics
Section titled “Semantics”- 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/effectthat 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 isnull,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 treat0as an object key, not an array index — usually not what you want. UsebindField()for array-element fields. This keepsbindPathpredictable 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 Signalif you pass an unwrapped value (e.g.bindPath(user.value, …)instead ofbindPath(user, …)).bindPath: path must be a non-empty array of keysifpathis empty or not an array.
When to use which
Section titled “When to use which”| Shape | Helper | When |
|---|---|---|
| Single signal you own | bind(signal) | One input ↔ one signal |
| Field inside an item inside a signal-held array | bindField(source, item, key) | Per-row inputs in lists, CRUD grids, editable tables |
| Field at a nested object path inside a signal-held record | bindPath(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 recipe — bindPath + 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 →