From 2d6f6c3fe73d457ea236e1d71b14ddccafdfef14 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Fri, 14 Aug 2026 16:09:49 +0300 Subject: [PATCH 1/2] docs: use `transformedValue` in `Forms | Custom Controls | Value transformation` section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Value transformation" section taught readers to hand-roll transformation with `linkedSignal()` and a manual parse method, even though `@angular/forms/signals` ships `transformedValue()` for exactly this case. Readers ended up with a weaker version of a feature the framework already provides — notably, no parse error reporting. Rewrite the section around `transformedValue()` and document the parts the manual pattern could not cover: returning `{error}` from `parse` to surface parse errors on the field's `errors()`, and `reset()` clearing them. This also makes good on the cross-reference from the validation guide, which pointed here for parse error details the section never covered. Fixes #70206 --- .../guide/forms/signals/custom-controls.md | 95 ++++++++++++++----- .../signals/src/api/transformed_value.ts | 11 +-- 2 files changed, 76 insertions(+), 30 deletions(-) diff --git a/adev/src/content/guide/forms/signals/custom-controls.md b/adev/src/content/guide/forms/signals/custom-controls.md index ba2427a7d2d5..4185b5cf32fe 100644 --- a/adev/src/content/guide/forms/signals/custom-controls.md +++ b/adev/src/content/guide/forms/signals/custom-controls.md @@ -390,43 +390,90 @@ IMPORTANT: Emit `touch` on `blur` (when focus leaves the control), not on `focus Controls sometimes display values differently than the form model stores them - a date picker might display "January 15, 2024" while storing "2024-01-15", or a currency input might show "$1,234.56" while storing 1234.56. -Use `linkedSignal()` (from `@angular/core`) to transform the model value for display, and handle input events to parse user input back to the storage format: +Use `transformedValue()` (from `@angular/forms/signals`) to keep the raw value shown in the UI in sync with the model value. It takes the control's `value` model signal plus a `parse` and a `format` function, and returns a writable signal holding the raw value: + +- `format` converts the model value into the raw value the template renders. +- `parse` converts what the user typed back into a model value, and can report parse errors instead. ```angular-ts -import {formatCurrency} from '@angular/common'; -import {ChangeDetectionStrategy, Component, linkedSignal, model} from '@angular/core'; -import {FormValueControl} from '@angular/forms/signals'; +import {Component, model} from '@angular/core'; +import {FormValueControl, transformedValue} from '@angular/forms/signals'; @Component({ - selector: 'app-currency-input', + selector: 'number-input', template: ` - + `, }) -export class CurrencyInput implements FormValueControl { - // Stores numeric value (1234.56) - readonly value = model.required(); +export class NumberInput implements FormValueControl { + readonly value = model.required(); - // Stores display value ("1,234.56") - readonly displayValue = linkedSignal(() => formatCurrency(this.value(), 'en', 'USD')); + protected readonly rawValue = transformedValue(this.value, { + parse: (val: string): number => ({value: val ? Number(val) : null}), + format: (val: number): string => val?.toString() ?? '', + }); +} +``` - // Update the model from the display value. - updateModel() { - this.value.set(parseCurrency(this.displayValue())); - } +Writing to the returned signal (`rawValue.set(...)`) runs `parse` and writes the result into `value`. Whenever the model changes from elsewhere - a `reset()`, a schema rule, or another part of the app - `format` runs again and the raw value updates to match. + +### Reporting parse errors + +Sometimes the raw value has no valid model representation - a half-typed date, or letters in a numeric field. The `NumberInput` above has this problem: `Number('abc')` is `NaN`, which `parse` happily writes into the model. + +Return `{error}` instead: + +```angular-ts +export class NumberInput implements FormValueControl { + readonly value = model.required(); + + protected readonly rawValue = transformedValue(this.value, { + parse: (val) => { + const parsed = val ? Number(val) : null; + + return Number.isNaN(parsed) + ? {error: {kind: 'parse', message: `${val} is not a number`}} + : {value: parsed}; + }, + format: (val) => val?.toString() ?? '', + }); } +``` -// Converts a currency string to a number (e.g. "USD1,234.56" -> 1234.56). -function parseCurrency(value: string): number { - return parseFloat(value.replace(/^[^\d-]+/, '').replace(/,/g, '')); +Return both `value` and `error` when you want to update the model _and_ flag a problem. + +When the control is bound with `[formField]`, parse errors are automatically reported to the field, so they show up in the field's `errors()` signal alongside validation errors: + +```angular-ts +@Component({ + imports: [NumberInput, FormField], + template: ` + + + @for (error of orderForm.amount().errors(); track $index) { + +

{{ error.message }}

+ } + `, +}) +export class Order { + orderModel = signal<{amount: number | null}>({amount: null}); + orderForm = form(this.orderModel); } ``` +A field with parse errors is invalid, which blocks submission the same way a failed validation rule does. + +HELPFUL: Signal Forms uses the same mechanism for native inputs. When the browser cannot parse a value (for example, a partially typed date in ``), it surfaces as a `parse` error on the field. See [Native HTML validation](guide/forms/signals/validation#native-html-validation) for details. + +### Resetting + +Calling `reset()` on the field clears any pending parse errors and re-formats the raw value from the model, so a control left in an unparseable state returns to a clean display value: + +```ts +orderForm.amount().reset(); +``` + ## Validation integration Controls display validation state but don't perform validation. Validation happens in the form schema - your control receives `invalid()` and `errors()` signals from the FormField directive and displays them (as shown in the StatefulInput example above). @@ -495,7 +542,7 @@ registrationForm = form(this.registrationModel, (path) => { The consumer's model must initialize every field with a defined value. In Signal Forms, `undefined` signifies the absence of a field and not an empty value. For a reusable email control, that means the consumer should use `''` as the initial value, and not leave the property undefined. See the [Form Models guide](guide/forms/signals/models) for details on choosing initial values. -In addition, controls should not register their own effects for state management. The form system manages field state through internal effects. This means that your control receives state updates through input signals. If a control needs to transform values, use `linkedSignal()` as shown in the "[Value transformation](#value-transformation)" section rather than an `effect()`. +In addition, controls should not register their own effects for state management. The form system manages field state through internal effects. This means that your control receives state updates through input signals. If a control needs to transform values, use `transformedValue()` as shown in the "[Value transformation](#value-transformation)" section rather than an `effect()`. ## Next steps diff --git a/packages/forms/signals/src/api/transformed_value.ts b/packages/forms/signals/src/api/transformed_value.ts index 5249d3380b5d..b4b22af6cef3 100644 --- a/packages/forms/signals/src/api/transformed_value.ts +++ b/packages/forms/signals/src/api/transformed_value.ts @@ -103,12 +103,11 @@ export interface TransformedValueSignal extends WritableSignal { * * protected readonly rawValue = transformedValue(this.value, { * parse: (val) => { - * if (val === '') return {value: null}; - * const num = Number(val); - * if (Number.isNaN(num)) { - * return {error: {kind: 'parse', message: `${val} is not numeric`}}; - * } - * return {value: num}; + * const parsed = val ? Number(val) : null; + * + * return Number.isNaN(parsed) + * ? {error: {kind: 'parse', message: `${val} is not a number`}} + * : {value: parsed}; * }, * format: (val) => val?.toString() ?? '', * }); From cbabf0a1439ed69e0502b04c4d286ae16d28c190 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Fri, 14 Aug 2026 16:47:44 +0300 Subject: [PATCH 2/2] chore: accept code review suggestion Co-authored-by: Matthieu Riegler --- adev/src/content/guide/forms/signals/custom-controls.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adev/src/content/guide/forms/signals/custom-controls.md b/adev/src/content/guide/forms/signals/custom-controls.md index 4185b5cf32fe..bde471143e13 100644 --- a/adev/src/content/guide/forms/signals/custom-controls.md +++ b/adev/src/content/guide/forms/signals/custom-controls.md @@ -423,7 +423,7 @@ Sometimes the raw value has no valid model representation - a half-typed date, o Return `{error}` instead: -```angular-ts +```ts export class NumberInput implements FormValueControl { readonly value = model.required();