Lab 17: Signal Forms
📖 Resources
Section titled “📖 Resources”🚀 Starter Code
Section titled “🚀 Starter Code”step-15-album-wholesale-v21-post-signal-computed
Continue from your Lab 16 result. If you need a fresh copy, use step-15-album-wholesale-v21-post-signal-computed as your base and complete Labs 15 and 16 first.
In this lab, you’ll adopt Angular’s new signal-based forms API. You’ll create forms using the form() function backed by a writable signal, bind inputs with the [field] directive, implement validators declaratively, and save by reading directly from the signal.
Note: Signal Forms are experimental in Angular 21. Add
"skipLibCheck": trueto yourtsconfig.jsonto avoid a known type definition issue in@angular/forms/signals.
📝 Instructions
Section titled “📝 Instructions”Step 1: Enable skipLibCheck
Section titled “Step 1: Enable skipLibCheck”In tsconfig.json, add "skipLibCheck": true to compilerOptions:
{ "compilerOptions": { "skipLibCheck": true }}Step 2: Create a Signal Form
Section titled “Step 2: Create a Signal Form”form() requires a writable signal as its model. The signal is the source of truth — the [field] directive updates it directly as the user types.
import { Component, inject, signal } from '@angular/core';import { form, required, min, validate, Field } from '@angular/forms/signals';import { Album } from '../model/album.model';
function priceEndsWithNine(value: number) { const lastDigit = value.toString().slice(-1); return lastDigit !== '9' ? { kind: 'priceEndingWith9' as const, message: 'Price must end with 9' } : null;}
@Component({ selector: 'app-album-add', templateUrl: './album-add.component.html', standalone: true, imports: [Field],})export class AlbumAddComponent { // The signal is the model — form() wraps it with validation private albumModel = signal<Album>({ id: 0, name: '', artist: '', description: '', price: 0, tags: [] });
albumForm = form(this.albumModel, (path) => { required(path.name); required(path.artist); min(path.price, 0); validate(path.price, (ctx) => priceEndsWithNine(ctx.value())); });
saveAlbum() { // Read current value directly from the signal — no .getRawValue() needed const album = this.albumModel(); inject(AlbumService).save(album).subscribe(...); }}Step 3: Use the [field] Directive
Section titled “Step 3: Use the [field] Directive”Bind each input to its corresponding field using [field]:
<mat-form-field> <mat-label>Name</mat-label> <input matInput [field]="albumForm.name"> @for (error of albumForm.name().errors(); track error.kind) { @if (error.kind === 'required') { <mat-error>Name is required</mat-error> } }</mat-form-field>
<mat-form-field> <mat-label>Artist</mat-label> <input matInput [field]="albumForm.artist"></mat-form-field>
<mat-form-field> <mat-label>Price</mat-label> <input matInput type="number" [field]="albumForm.price"> @for (error of albumForm.price().errors(); track error.kind) { @if (error.kind === 'min') { <mat-error>Price must be 0 or greater</mat-error> } @if (error.kind === 'priceEndingWith9') { <mat-error>Price must end with 9</mat-error> } }</mat-form-field>
<button mat-raised-button (click)="saveAlbum()">Save</button>Step 4: Custom Validator
Section titled “Step 4: Custom Validator”A custom validator is a function that receives the current value and returns an error object or null:
function priceEndsWithNine(value: number) { const lastDigit = value.toString().slice(-1); return lastDigit !== '9' ? { kind: 'priceEndingWith9' as const, message: 'Price must end with 9' } : null;}
// Apply with validate():validate(path.price, (ctx) => priceEndsWithNine(ctx.value()));The kind property is used in the template @if (error.kind === 'priceEndingWith9') to show the right error message.

