import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
function hasActualValue(control: AbstractControl | null): boolean {
return !!control && control.value !== null && control.value !== '';
}
export function passwordMatchValidator(
passwordKey: string,
confirmKey: string
): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get(passwordKey);
const confirm = group.get(confirmKey);
if (!password || !confirm) {
return null;
}
// Don't fire before the user has typed a confirmation.
if (!hasActualValue(confirm)) {
return null;
}
const mismatch = password.value !== confirm.value;
const existing = confirm.errors ?? {};
if (mismatch) {
confirm.setErrors({ ...existing, passwordMismatch: true });
return { passwordMismatch: true };
}
// Clear only our key so required/minLength survive.
const { passwordMismatch, ...rest } = existing;
confirm.setErrors(Object.keys(rest).length ? rest : null);
return null;
};
}
import { Component } from '@angular/core';
import { AbstractControl, FormBuilder, Validators } from '@angular/forms';
import { passwordMatchValidator } from './password-match.validator';
@Component({
selector: 'app-signup-form',
templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
form = this.fb.group(
{
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', [Validators.required]],
},
{ validators: passwordMatchValidator('password', 'confirmPassword') }
);
constructor(private fb: FormBuilder) {}
get confirmControl(): AbstractControl | null {
return this.form.get('confirmPassword');
}
get mismatch(): boolean {
const c = this.confirmControl;
return !!c && c.hasError('passwordMismatch') && (c.touched || c.dirty);
}
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
// proceed with this.form.getRawValue()
}
}
<form [formGroup]="form" (ngSubmit)="submit()">
<label>
Email
<input type="email" formControlName="email" autocomplete="email" />
</label>
<label>
Password
<input type="password" formControlName="password" autocomplete="new-password" />
</label>
<label>
Confirm password
<input type="password" formControlName="confirmPassword" autocomplete="new-password" />
</label>
<p class="error" *ngIf="mismatch">Passwords do not match</p>
<p class="error" *ngIf="confirmControl?.hasError('required') && confirmControl?.touched">
Please confirm your password
</p>
<button type="submit" [disabled]="form.invalid">Create account</button>
</form>
This snippet shows how to validate that two fields — a password and its confirmation — match in an Angular reactive form. The subtle part is that the matching rule spans two controls, so it cannot live on either control individually; it belongs to the FormGroup that contains both.
In password-match.validator.ts, passwordMatchValidator is a factory that returns a ValidatorFn bound to the two control names. Applying it at the group level is the idiomatic way to express a cross-field constraint in Angular: the validator receives the AbstractControl for the whole group and reads its children with group.get(...). When the values differ it writes a passwordMismatch error onto the confirm control via setErrors, rather than only onto the group, so the error sits next to the field the user must fix. Crucially it merges with any existing errors and clears only the passwordMismatch key when values match, so it never stomps other validators such as required or minLength. It also short-circuits while the confirm field is empty so the mismatch message doesn't fire before the user has typed anything.
The hasActualValue guard avoids a common pitfall where an unrelated required error would otherwise be masked by the mismatch state. Returning the error object from the group as well keeps form.valid honest for consumers that inspect the group directly.
In signup-form.component.ts, the form is assembled with FormBuilder. The validator is passed as the second argument to group(...) — the group-level options slot — which is where cross-field validators must be registered. The component exposes small getters like confirmControl and mismatch so the template stays declarative, and it only surfaces the message once the control is touched or dirty to keep the UX calm.
The signup-form.component.html template wires each input to its control and shows Passwords do not match conditionally. Because the error lives on the confirm control, the template reads confirmControl?.errors?.['passwordMismatch'] directly. This approach scales to other paired fields (email confirmation, date ranges) by parameterizing the two control names, and keeps validation logic pure, testable, and free of manual subscriptions.
Related snips
class Product(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
cost = models.DecimalField(max_digits=10, decimal_places=2)
margin = models.DecimalField(max_digits=5, decimal_places=2, blank=True)
Django model signals vs overriding save
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
Share this code
Here's the card — post it anywhere.