Angular is finally updating its forms to use signals. This means you have to relearn how to validate your data, hopefully for the best.
I have built a profile form with a username async validator and a password matching validator in both Reactive Forms and Signal Forms. Both versions work well, but the new signal forms binds with the pure HTML form validation automatically.

Make sure you install the latest version of Angular globally.
npm install -g @angular/cli
Then you can update to the next branch.
npx ng update --next
π At the time of this writing, I used version 21.0.0-next.9, so anything could change.
You should also install tailwind if you want easy styling.
I created a simple shared error component for displaying our error messages.
errors = input<string[]>([]);
We pass the errors as an input, then loop through each one and show them.
@let showErrors = errors();
@if (showErrors?.length) {
<ul class="text-red-600 text-sm list-disc list-inside">
@for (e of showErrors; track ([e, $index])) {
<li>{{ e }}</li>
}
</ul>
}
π It will not display if there are no errors. It shows errors under each field in both versions.
We must import ReactiveFormsModule as well as our custom ShowErrors component.
imports: [ReactiveFormsModule, ShowErrors]
Let’s start with the classic version first, so we know what we are doing. First, we have our custom validators.
π I put all of these in the same file for example sake, but in production you would want to everything in separate files for best practice.
You could just use the Validators.pattern directly, but this allows you to control the validator name.
export const phoneNumberValidator: ValidatorFn = (
control: AbstractControl
): ValidationErrors | null => {
return Validators.pattern(/^\+?[0-9\s-]+$/)(control)
? { phoneNumber: true }
: null;
};
The match validator, which I created to solve the confirm password problem, allows you to place the same validator on two different fields and only show the error on one of them when the fields do not match. You can read more about it in my article Angular Confirm Custom Validator.
export function matchValidator(
matchTo: string,
reverse?: boolean
): ValidatorFn {
return (control: AbstractControl):
ValidationErrors | null => {
if (control.parent && reverse) {
const c = (
control.parent?.controls as Record<string, AbstractControl>
)[matchTo] as AbstractControl;
if (c) {
c.updateValueAndValidity();
}
return null;
}
return !!control.parent &&
!!control.parent.value &&
control.value ===
(
control.parent?.controls as Record<string, AbstractControl>
)[matchTo].value
? null
: { matching: true };
};
}
In a production app, we may need to fetch our database to see if a username is available. This would be the same premise to checking on unique slugs, emails or even promo codes.
export function usernameAvailableValidator(delayMs = 400): AsyncValidatorFn {
const checkUsername = inject(USERNAME_VALIDATOR);
let timer: ReturnType<typeof setTimeout>;
return (control: AbstractControl): Promise<ValidationErrors | null> => {
const value = control.value;
if (!value) return Promise.resolve(null);
clearTimeout(timer);
return new Promise((resolve) => {
timer = setTimeout(async () => {
try {
const available = await checkUsername(value);
resolve(available ? null : { taken: true });
} catch {
resolve(null);
}
}, delayMs);
});
};
}
We call our function checkUsername, an async function that returns true of false if it is available. We can use a timeout so that the database is not called on every letter typed in a field, thus avoiding certain race conditions. You could also do an observable version, but I prefer handling this with promises.
checkUsername
I created an injection token which can search for an available username. This emulates a real database call.
import { InjectionToken } from "@angular/core";
export const USERNAME_VALIDATOR = new InjectionToken(
'username-validator',
{
providedIn: 'root',
factory() {
return async (username: string) => {
const takenUsernames = ['admin', 'user', 'test'];
await new Promise((resolve) => setTimeout(resolve, 500));
return !takenUsernames.includes(username);
}
}
}
);
π I could have used a regular function here, but a production app will probably require other services to be injected and shared as well.
We must define our form errors in a JSON object. There are many different ways we could store this, but I find this way to be the easiest to maintain.
profileForm: FormGroup;
errorMessages: Record<string, Record<string, string>> = {
firstName: {
required: 'First name is required.',
minlength: 'First name must be at least 2 characters.'
},
lastName: {
required: 'Last name is required.',
minlength: 'Last name must be at least 2 characters.'
},
biograph: {
maxlength: 'Biography cannot exceed 200 characters.'
},
phoneNumber: {
phoneNumber: 'Enter a valid phone number.'
},
username: {
required: 'Username is required.',
minlength: 'Username must be at least 3 characters.',
taken: 'This username is already taken.'
},
birthday: {
required: 'Birthday is required.'
},
password: {
required: 'Password is required.',
matching: 'Passwords must match.'
},
confirmPassword: {
required: 'Confirm password is required.',
matching: 'Passwords must match.'
}
};
π Our keys must match the error keys they produce, for example, phoneNumber.
If you return the abstract controls types correctly, you can use them just like any built-in Angular Validator.
this.profileForm = this.fb.group({
firstName: ['', [Validators.required, Validators.minLength(2)]],
lastName: ['', [Validators.required, Validators.minLength(2)]],
biograph: ['', Validators.maxLength(200)],
phoneNumber: ['', phoneNumberValidator],
username: ['', [Validators.required, Validators.minLength(3)], usernameAvailableValidator()],
birthday: ['', Validators.required],
password: ['', [Validators.required, matchValidator('confirmPassword', true)]],
confirmPassword: ['', [Validators.required, matchValidator('password')]]
});
π Notice the usenameAvailableValidator is outside of the second array. This is because we put our asynchronous validators in the third parameter for our FormGroup.
We need to map our errors to the error messages themselves.
getErrors(controlName: string): string[] {
const control = this.profileForm.get(controlName);
if (!control || !control.errors || (!control.touched && !control.dirty)) {
return [];
}
const messagesForField = this.errorMessages[controlName] ?? {};
return Object.keys(control.errors)
.map(key => messagesForField[key])
.filter((msg): msg is string => !!msg);
}
π Notice we check for touched and dirty states before checking for an error. We don’t want a blank form showing error messages before any typing happens or the form control has been focused.
For submit, we just alert the data for testing purposes.
onSubmit(): void {
if (this.profileForm.valid) {
alert('Profile Data: ' + JSON.stringify(this.profileForm.value));
} else {
alert('Form is invalid');
}
}
The template is straightforward. We bind our formGroup to the form, handle onSubmit, bind the individual form controls to formControlName, and handle errors in with getErrors('name') using our custom error component.
<form [formGroup]="profileForm" (ngSubmit)="onSubmit()" class="space-y-4 p-4 max-w-md mx-auto">
<div>
<label class="block mb-1 font-semibold">First Name</label>
<input type="text" formControlName="firstName" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('firstName')" />
</div>
<div>
<label class="block mb-1 font-semibold">Last Name</label>
<input type="text" formControlName="lastName" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('lastName')" />
</div>
<div>
<label class="block mb-1 font-semibold">Biography</label>
<textarea #bio formControlName="biograph" rows="3" class="w-full border p-2 rounded"></textarea>
<app-show-errors [errors]="getErrors('biograph')" />
</div>
<div>
<label class="block mb-1 font-semibold">Phone Number</label>
<input type="tel" formControlName="phoneNumber" placeholder="+1 555-123-4567"
class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('phoneNumber')" />
</div>
<div>
<label class="block mb-1 font-semibold">Username</label>
<input type="text" formControlName="username" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('username')" />
</div>
<div>
<label class="block mb-1 font-semibold">Birthday</label>
<input type="date" formControlName="birthday" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('birthday')" />
</div>
<div>
<label class="block mb-1 font-semibold">Password</label>
<input type="password" formControlName="password" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('password')" />
</div>
<div>
<label class="block mb-1 font-semibold">Confirm Password</label>
<input type="password" formControlName="confirmPassword" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('confirmPassword')" />
</div>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
[disabled]="profileForm.invalid">
Save Profile
</button>
<p>HTML Form Validation: {{ bio.getAttribute('maxlength') === '200' }}</p>
</form>
π I also tagged our #bio field so that we can check for HTML validation synchronization. It does not exist in Reactive Forms version. getAttribute is a native HTML function.
We must import the Field control as well as our ShowErrors custom component.
imports: [Field, ShowErrors],
π Field is preceded by Control, which no longer exists in the latest version.
The signal version has all the same parts, but the implementation is completely different.
The signal version uses functions like pattern to validate our data. Again, I chose to use this function and customize it. I have to return customError with the proper typing.
export function phoneNumber(
field: Parameters<typeof pattern>[0],
opts?: { message?: string }
) {
return pattern(field, /^\+?[0-9\s-]+$/, {
error: customError({
kind: 'phoneNumber',
message: opts?.message ?? 'Invalid phone number format.'
})
});
}
Our password validator is a lot simpler. We only need to run this on one function using the validate function, since it will rerun on every keystroke anyway.
export function matchField<T>(
field: Parameters<typeof validate<T>>[0],
matchToField: Parameters<typeof validate<T>>[0],
opts?: {
message?: string;
}
) {
return validate(field, (ctx) => {
const thisVal = ctx.value();
const otherVal = ctx.valueOf(matchToField);
if (thisVal === otherVal) {
return null;
}
return customError({
kind: 'matching',
message: opts?.message ?? 'Values must match.'
});
});
}
Our username validator was a lot more complex, unnecessarily more complex in my opinion. It requires you to use validateAsync, which itself requires a resource function with a loader and parameters. I have added a timeout to handle fast typing as well. We use our checkUsername token function.
export function usernameAvailable(
field: Parameters<typeof pattern>[0],
delayMs = 400,
opts?: { message?: string }
) {
const checkUsername = inject(USERNAME_VALIDATOR);
return validateAsync(field, {
params: (ctx) => ({
value: ctx.value()
}),
factory: (params) => {
let timer: ReturnType<typeof setTimeout>;
return resource({
params,
loader: async (p) => {
const value = p.params.value;
clearTimeout(timer);
return new Promise<boolean>((resolve) => {
timer = setTimeout(async () => {
const available = await checkUsername(value);
resolve(available);
}, delayMs);
});
}
})
},
errors: (result) => {
if (!result) {
return {
kind: 'taken',
message: opts?.message ?? 'This username is already taken.'
};
}
return null;
}
});
}
π This took me the most time to get right.
Instead of a form group, signal forms uses a typed schema.
type Profile = {
firstName: string;
lastName: string;
biograph: string;
phoneNumber: string;
username: string;
birthday: string;
password: string;
confirmPassword: string;
};
const profileSchema = schema<Profile>((p) => {
required(p.firstName, {
message: 'First name is required.'
});
minLength(p.firstName, 2, {
message: 'First name must be at least 2 characters.'
});
required(p.lastName, {
message: 'Last name is required.'
});
minLength(p.lastName, 2, {
message: 'Last name must be at least 2 characters.'
});
maxLength(p.biograph, 200, {
message: 'Biography cannot exceed 200 characters.'
});
required(p.username, {
message: 'Username is required.'
});
minLength(p.username, 3, {
message: 'Username must be at least 3 characters.'
});
required(p.birthday, {
message: 'Birthday is required.'
});
required(p.phoneNumber, {
message: 'Phone number is required.'
});
required(p.password, {
message: 'Password is required.'
});
required(p.confirmPassword, {
message: 'Confirm password is required.'
});
phoneNumber(p.phoneNumber, {
message: 'Enter a valid phone number.'
});
matchField(p.confirmPassword, p.password, {
message: 'Passwords must match.'
});
usernameAvailable(p.username, 400, {
message: 'This username is already taken.'
});
});
This is nice because it makes things clear, but it’s also overly verbose. We enter our error messages directly in the function validators, which helps us avoid an extraneous JSON object.
private initial = signal<Profile>({
firstName: '',
lastName: '',
biograph: '',
phoneNumber: '',
username: '',
birthday: '',
password: '',
confirmPassword: ''
});
profileForm = form(this.initial, profileSchema);
We need an actual signal to handle the changes.
Our error function is similar, and we map the errors to our fields.
getErrors(controlName: keyof typeof this.profileForm): string[] {
const field = this.profileForm[controlName];
const state = field();
// Only show errors after user interaction
if (!state.touched() && !state.dirty()) return [];
const errors = state.errors();
if (!errors) return [];
return errors
.map(err => err.message ?? err.kind ?? 'Invalid')
.filter(Boolean);
}
Our template is very similar, but we use field to bind our form values.
<form (submit)="$event.preventDefault(); onSubmit()" class="space-y-4 p-4 max-w-md mx-auto">
<div>
<label class="block mb-1 font-semibold">First Name</label>
<input type="text" [field]="profileForm.firstName" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('firstName')" />
</div>
<div>
<label class="block mb-1 font-semibold">Last Name</label>
<input type="text" [field]="profileForm.lastName" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('lastName')" />
</div>
<div>
<label class="block mb-1 font-semibold">Biography</label>
<textarea #bio rows="3" [field]="profileForm.biograph" class="w-full border p-2 rounded"></textarea>
<app-show-errors [errors]="getErrors('biograph')" />
</div>
<div>
<label class="block mb-1 font-semibold">Phone Number</label>
<input type="tel" placeholder="+1 555-123-4567" [field]="profileForm.phoneNumber"
class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('phoneNumber')" />
</div>
<div>
<label class="block mb-1 font-semibold">Username</label>
<input type="text" [field]="profileForm.username" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('username')" />
</div>
<div>
<label class="block mb-1 font-semibold">Birthday</label>
<input type="date" [field]="profileForm.birthday" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('birthday')" />
</div>
<div>
<label class="block mb-1 font-semibold">Password</label>
<input type="password" [field]="profileForm.password" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('password')" />
</div>
<div>
<label class="block mb-1 font-semibold">Confirm Password</label>
<input type="password" [field]="profileForm.confirmPassword" class="w-full border p-2 rounded" />
<app-show-errors [errors]="getErrors('confirmPassword')" />
</div>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
[disabled]="profileForm().invalid()">
Save Profile
</button>
<p>HTML Form Validation: {{ bio.getAttribute('maxlength') === '200' }}</p>
</form>
And that’s it!
Which one do you prefer?
Repo: GitHub
Demo: Vercel Functions
Jonathan Gamble has been an avid web programmer for more than 20 years. He has been building web applications as a hobby since he was 16 years old, and he received a post-bachelor’s in Computer Science from Oregon State. His real passions are language learning and playing rock piano, but he never gets away from coding. Read more from him at https://code.build/.