Read More on Telerik Blogs
November 18, 2025 Web, Angular
Get A Free Trial

Angular is finally updating its forms to use signals. This means you have to relearn how to validate your data, hopefully for the best.

TL;DR

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.

Installation

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.

Tailwind

You should also install tailwind if you want easy styling.

Error Component

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.

Reactive Version

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.

Phone Validator

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;
};

Match Validator

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 };
  };
}

Username Validator

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.

Creating the Form

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.

Validators

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.

Error Testing

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.

Submit

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');
  }
}

Template

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.

Signal Version

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.

Phone Validator

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.'
    })
  });
}

Match Validator

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.'
    });
  });
}

Username Validator

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.

Creating the Schema

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.

Error Testing

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);
}

Signal Template

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


About the Author

Jonathan Gamble

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/.

 

 

Related Posts