skip to content

In Angular, how does [(value)]="volume" work when volume is a WritableSignal, and why is [(value)]="volume()" rejected?

level: middleimportance: should knowfreq 52%

answer

  1. pass the signal, not its value
  2. set if writable, else assign
  3. the target must be assignable
  4. read-only signals fail type checking

basics

~20 s

The compiler passes the signal itself: the property half reads its value and the event half calls set($event) on it. volume() is a call, not an assignable target, so the parser reports an unsupported expression.

solid answer

~40 s

Since v17.2 the target of a two-way binding may be a `WritableSignal`. The template must name the signal, `[(value)]="volume"`, not its value. The property half unwraps it and passes the current number to the child's input. The compiled event half is `twoWayBindingSet(ctx.volume, $event) || (ctx.volume = $event)`: if the target is a writable signal, Angular calls `set($event)`; otherwise it falls back to plain assignment. Writing `volume()` makes the target a function call, and a call is not assignable, so the template parser reports *Unsupported expression in a two-way binding*. Allowed targets are property and keyed reads, optionally wrapped in `!` or `$any()`, and not through `?.`. A read-only signal such as `computed()` or a signal `input()` is not writable, and the type checker rejects it.

code

ts · 17 lines
ts
import { Component, computed, signal } from '@angular/core';
import { VolumeSlider } from './volume-slider';

@Component({
  selector: 'app-mixer',
  imports: [VolumeSlider],
  template: `
    <app-volume-slider [(value)]="volume" />          <!-- ok: signal instance -->
    <app-volume-slider [value]="percent()" />         <!-- one-way from a computed -->
    <!-- <app-volume-slider [(value)]="volume()" />  parse error: unsupported expression -->
    <!-- <app-volume-slider [(value)]="percent" />   type error: computed is read-only -->
  `,
})
export class Mixer {
  volume = signal(40);
  percent = computed(() => Math.round(this.volume()));
}

go deeper

for a junior

Recall that the template names the signal without parentheses in a two-way binding.

for a middle

Explain the generated set-or-assign listener and which expressions the parser accepts as targets.

for a senior

Explain why read-only signals fail type checking and how forwarding a model() through two-way binding propagates upward.

for a principal

Frame when components should expose two-way state at all versus one-way input plus explicit events.

## Two-way binding to a signal Signals became the default way to hold component state, so two-way binding had to accept them. Since **v17.2**, the expression in `[( )]` may be a **`WritableSignal`**: ```ts @Component({ selector: 'app-player', imports: [VolumeSlider], template: `<app-volume-slider [(value)]="volume" />`, }) export class Player { volume = signal(40); } ``` The key point is that the template names **the signal object**, not the value it holds. Angular needs the object because the event half has to write into it. ## What the compiler generates For the binding above, Angular's compiler emits a two-way property instruction and a two-way listener. Simplified: ```ts // update: property half twoWayProperty('value', ctx.volume); // unwraps a WritableSignal to its value // create: event half twoWayListener('valueChange', ($event) => { twoWayBindingSet(ctx.volume, $event) || (ctx.volume = $event); return $event; }); ``` 1. **Property half.** `twoWayProperty` checks whether the value is a writable signal. If it is, it reads it (`volume()`) and passes the number to the child's input. Otherwise it passes the value as is. 2. **Event half.** `twoWayBindingSet` calls `set($event)` and returns `true` when the target is a writable signal. For a plain field it returns `false`, and the `||` falls back to ordinary assignment. The same template therefore works whether `volume` is `signal(40)` or a plain `40`. Switching a field to a signal needs no template change. ## What the target is allowed to be The template parser checks the right-hand side of every two-way binding. It accepts only expressions that can be assigned to: | Expression | Accepted? | Why | |---|---|---| | `volume` | yes | property read | | `settings.volume` | yes | nested property read | | `levels[channel]` | yes | keyed read | | `volume!` or `$any(volume)` | yes | wrappers around an assignable read | | `volume()` | no | a call; nothing to assign to | | `getVolume()` | no | a call | | `settings?.volume` | no | a safe-navigation read cannot be assigned | Rejected expressions produce the parse error *Unsupported expression in a two-way binding*. `volume()` is the mistake people make most, because every other place in a modern template reads signals with parentheses. The rule to remember: **reads use `()`, the two-way target does not**. ## Signals that cannot be targets Passing a signal is only useful if it can be written: - A **`computed()`** signal is read-only. - A signal **`input()`** is read-only from inside the component that declares it. - A **`model()`** input is writable, so a component can forward its own model to a child with `[(value)]="value"`. The template type checker models the property half as accepting `T | WritableSignal<T>`. A read-only signal is neither, so binding one fails type checking when input types are checked (`strictTemplates`) instead of silently doing nothing at runtime. ## Why a signal target is worth it The parent view refreshes either way: the listener generated for the event half is wrapped like any template listener, so it marks the parent view dirty whenever the child emits. What a signal adds is reach. `set()` also notifies every other reader of `volume`, such as a `computed()` elsewhere in the component or another view that reads it, so derived state updates without extra wiring. ## Migrating a field to a signal Moving a component from a plain field to a signal is a common modernisation step, and two-way bindings make it smooth: 1. Change the declaration from `volume = 40` to `volume = signal(40)`. 2. Leave every `[(value)]="volume"` as it is; the compiled listener now takes the `set()` branch. 3. Update every **read** of the field: `{{ volume }}` becomes `{{ volume() }}`, `this.volume` in TypeScript becomes `this.volume()`. 4. Update every **write** outside the template: `this.volume = x` becomes `this.volume.set(x)`. 5. Leave expanded forms such as `[value]="volume" (valueChange)="volume = $event"` alone only if you also fix them: the longhand needs `volume()` and `volume.set($event)` explicitly, because only the `[( )]` shorthand performs the automatic unwrapping. The last step is where migrations go wrong: people assume the longhand behaves like the shorthand. It does not; the set-or-assign logic is generated only for the two-way form. ## Summary - Name the signal: `[(value)]="volume"`. - The compiled listener uses `set()` for writable signals and assignment otherwise. - Calls, safe navigation and read-only signals are not valid targets.

  • Can an Angular component forward its own model() input to a child with [(value)]?
    Yes. A `model()` input is a writable signal, so `[(value)]="value"` inside the component passes it down and calls `set()` on it when the child emits. The model then emits its own `valueChange`, so the change also reaches the component's parent.
  • Why does Angular accept [(value)]="settings.volume" but not [(value)]="settings?.volume"?
    The parser only accepts assignable reads. `settings.volume` is a property read the listener can assign to. With safe navigation the read may short-circuit to `undefined`, and there is no valid assignment target in that case, so the parser rejects it.

saying these in an interview costs you the question

  • The template should call the signal: [(value)]="volume()"
  • Two-way binding only works with plain fields, not signals
  • A computed() signal can be the target of a two-way binding
  • Switching a field to a signal requires rewriting the two-way template
  • The event half always assigns with =, even for signals