Updated on

Angular validates forms in two ways: template-driven, where the rules are attributes in the HTML, and reactive, where the rules are TypeScript in the component class. This article uses the reactive approach to build an owner-creation form, show a message for each rule that fails, and POST the result to our Web API.

The pieces are a FormGroup holding one FormControl per input, an array of Validators on each control, and a template that names controls by string rather than declaring them.

In Angular @Input and @Output Decorators and Directives we built the success and error modal components. We use both here.

For the complete navigation and all the basic instructions of the Angular series, check out: Introduction of the Angular series.

To download the source code for this article, you can visit the FormValidationAndPostRequests folder in our GitHub repository. The source code for the whole Angular series is here.
The requests on this page reach the Web API we built in the first half of this series, whose final state lives in the UsingRepositoryForWriteRequests folder. Start it on its http launch profile, so it listens on http://localhost:5000, which is the address the environment file from part 9 points at.

Let’s start.

How Do We Prepare the Create Owner Component?

Let’s start by creating our component inside the owner folder. To do this, execute the Angular CLI command:

ng g component owner/owner-create --skip-tests

Then, we are going to modify the owner routes by adding a new entry to the owner.routes.ts file:

{ path: 'create', component: OwnerCreate }

When we click on the “Create owner” link inside the owner-list.html file, we want the application to direct us to the creation page.

So, let’s modify the <a> tag inside the owner-list.html file:

<a [routerLink]="['/owner/create']">Create owner</a>

That tag was a plain href="#" placeholder until now, so the OwnerList component has to import RouterLink for the binding to resolve. A standalone component names every directive its own template uses.

What Are the Two Types of Form Validation in Angular?

Angular ships two ways to validate a form, and they differ in where the rules live.

Template-driven validation keeps the rules in the HTML as attributes. We put required or maxlength on an input, bind it with ngModel, and Angular assembles the form model behind the scenes.

Reactive validation keeps the rules in the component class. We build a FormGroup in TypeScript, hand each FormControl an array of validators, and the template only names which control each input is bound to.

We take the reactive route here for three reasons. The rules are ordinary TypeScript, so they can be unit tested without rendering anything. The form model is typed, so a control reached through ownerForm.controls is checked at build time rather than resolving to nothing at runtime. And controls can be added or removed while the form is running, which a fixed set of HTML attributes cannot express.

To use any of it, the component imports ReactiveFormsModule into its own imports array.

If the project already uses Angular Material, its form-field components wrap the same reactive controls, and we cover that combination in Angular Material form validation.

Just before we modify our component, let’s add that import. A standalone component asks for what it needs directly, so ReactiveFormsModule goes into its decorator:

import { ReactiveFormsModule } from '@angular/forms';

@Component({
  imports: [ReactiveFormsModule],
  selector: 'app-owner-create',
  styleUrl: './owner-create.css',
  templateUrl: './owner-create.html',
})
export class OwnerCreate implements OnInit {

Earlier versions of this series registered ReactiveFormsModule once in an owner.module.ts file. There is no module here, so the component declares what it needs and nothing else in the application has to know about it.

Additionally, we have to create a new interface:

export interface OwnerForCreation {
  name: string;
  dateOfBirth: string;
  address: string;
}

How Do We Build a Validated Angular Form Template?

Let’s continue by modifying the owner-create.html file:

<div class="container-fluid">
  <form [formGroup]="ownerForm" autocomplete="off" novalidate (ngSubmit)="createOwner(ownerForm.value)">
    <div class="card card-body bg-light mb-2 mt-2">

      <div class="row mb-3">
        <label for="name" class="col-form-label col-md-2">Name of the owner: </label>
        <div class="col-md-5">
          <input type="text" formControlName="name" id="name" class="form-control" />
        </div>
        <div class="col-md-5">
          @if (validateControl('name') && hasError('name', 'required')) {
            <em>Name is required</em>
          }
          @if (validateControl('name') && hasError('name', 'maxlength')) {
            <em>Maximum allowed length is 60 characters.</em>
          }
        </div>
      </div>

      <div class="mb-3 row">
        <label for="dateOfBirth" class="col-form-label col-md-2">Date of birth: </label>
        <div class="col-md-5">
          <input type="text" formControlName="dateOfBirth" id="dateOfBirth"
          class="form-control" readonly bsDatepicker/>
        </div>
        <div class="col-md-5">
          @if (validateControl('dateOfBirth') && hasError('dateOfBirth', 'required')) {
            <em>Date of birth is required</em>
          }
        </div>
      </div>

      <div class="mb-3 row">
        <label for="address" class="col-form-label col-md-2">Address: </label>
        <div class="col-md-5">
          <input type="text" formControlName="address" id="address" class="form-control" />
        </div>
        <div class="col-md-5">
          @if (validateControl('address') && hasError('address', 'required')) {
            <em>Address is required</em>
          }
          @if (validateControl('address') && hasError('address', 'maxlength')) {
            <em>Maximum allowed length is 100 characters.</em>
          }
        </div>
      </div>

      <br><br>

      <div class="mb-3 row">
          <div class="offset-5 col-md-1">
              <button type="submit" class="btn btn-info" [disabled]="!ownerForm.valid">Save</button>
          </div>
          <div class="col-md-1">
              <button type="button" class="btn btn-danger" (click)="redirectToOwnerList()">Cancel</button>
          </div>
      </div>

    </div>
  </form>
</div>

Now let’s explain this code. In the form element, we create the formGroup with the ownerForm name. This form group contains all the controls we need to validate in our form. Moreover, with the (ngSubmit) we call a function when a user presses the submit button. As a parameter for that function, we send the ownerForm’s value which contains all the controls with the data we need for the validation.

There is a formControlName attribute inside every control. That attribute represents the control name which we are going to validate inside the ownerForm and it is a mandatory attribute. Furthermore, in the <em> tags we display error messages if there are any, and each message sits inside its own @if block, Angular’s built-in control flow.

Following one value all the way to the API shows how little holds the two halves together: the string in formControlName is the only link between the template and the control that stores what the user types.

Value flows from a form input through its FormControl and validators into the object POSTed to the API.

One thing to pay attention to is the DateOfBirth input element. Here we use the bsDatepicker directive from ngx-bootstrap, to attach the datepicker to this input component. For this to work, the component has to import two directives, not one:

import { BsDatepickerDirective, BsDatepickerInputDirective } from 'ngx-bootstrap/datepicker';

@Component({
  imports: [ReactiveFormsModule, BsDatepickerDirective, BsDatepickerInputDirective],
  ...
})

The second one is easy to miss and the form does not work without it. BsDatepickerDirective is what opens the calendar popup, while BsDatepickerInputDirective is the one that carries the ControlValueAccessor. Leave it out and the picked date never reaches the dateOfBirth control, so the form stays invalid and the Save button never enables, which looks like a validation bug rather than a missing import.

There are a lot more functionalities that we can use with ngx-bootstrap’s date picker, and to learn about them, you can read this documentation.

About the Errors and Buttons on the Form

Errors will be written on the page only if the functions validateControl() and hasError() return true as a result.

The validateControl() function is going to check if the control is invalid and the hasError() function is going to check which validation rules we are validating against (required, max length and so on). The error name we pass is the framework’s, not ours: Angular’s Validators API reference describes what maxLength hands back as “A validator function that returns an error map with the maxlength property if the validation check fails, otherwise null.” That is why the template above asks for the all-lowercase spelling. Both validateControl and hasError functions are the custom functions that we are going to implement in the component (.ts) file. There is also a submit button that is going to be disabled until the form becomes valid and a cancel button that is going to redirect the user away from the creation form.

How Do We Wire Validation in the Component Class?

The component owns the form. We declare an ownerForm property, build it in ngOnInit(), and give every control its validators there.

Each FormControl takes two arguments: the value it starts with, and an array of validator functions. Validators.required and Validators.maxLength(60) are two of the eleven Angular ships.

The keys of the object passed to FormGroup must match the formControlName attributes in the template exactly. Name a control the group lacks and Angular throws Cannot find control with name. The reverse is silent: a group key no input claims quietly stops validating.

Two small helpers do the rest. One reports whether a control is both invalid and touched, so a field nobody has visited shows no error. The other asks which rule was broken, so each message can be shown on its own line rather than as one generic complaint.

The submit handler checks the form once more before sending, because a disabled button is a convenience rather than a guarantee.

Let’s start with the imports:

import { DatePipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { BsDatepickerDirective, BsDatepickerInputDirective } from 'ngx-bootstrap/datepicker';
import { BsModalRef, BsModalService, ModalOptions } from 'ngx-bootstrap/modal';

import { Owner } from '../../_interfaces/owner.model';
import { OwnerForCreation } from '../../_interfaces/ownerForCreation.model';
import { OwnerRepositoryService } from '../../shared/services/owner-repository.service';
import { SuccessModal } from '../../shared/modals/success-modal/success-modal';

A lot of import statements, and we will see throughout the code why we use each of those.

Now, let’s inspect another part of the file:

export class OwnerCreate implements OnInit {
  ownerForm!: FormGroup;
  bsModalRef?: BsModalRef;

  private repository = inject(OwnerRepositoryService);
  private router = inject(Router);
  private datePipe = inject(DatePipe);
  private modal = inject(BsModalService);

  ngOnInit(): void {
    this.ownerForm = new FormGroup({
      name: new FormControl('', [Validators.required, Validators.maxLength(60)]),
      dateOfBirth: new FormControl('', [Validators.required]),
      address: new FormControl('', [Validators.required, Validators.maxLength(100)])
    });
  }
}

As soon as a component mounts we are initializing our FormGroup variable named ownerForm with all the FormControls. Pay attention that the keys in the ownerForm object are the same as the names in the formControlName attribute for all input fields in a .html file, which is mandatory. Moreover, they have the same name as the properties inside the owner object (address, dateOfBirth, and name).

When instantiating a new form control as a first parameter we are providing the value of control and as a second parameter the Validators array, which holds all the validation rules for our controls.

The four services arrive through inject() rather than through a constructor, and ownerForm carries a definite assignment assertion because strict mode is on and the property is filled in ngOnInit() rather than at its declaration.

Handling Errors

Now, we are going to add those two functions that we call in our template file to handle validation errors:

validateControl = (controlName: string) => {
  const control = this.ownerForm.get(controlName);

  return control !== null && control.invalid && control.touched;
}

hasError = (controlName: string, errorName: string) => {
  return this.ownerForm.get(controlName)?.hasError(errorName) ?? false;
}

In the validateControl() method, we are checking if the current control is invalid and touched (we don’t want to show an error if the user didn’t place the cursor inside the control at all). Furthermore, the hasError() function will check which validation rule the current control has violated.

Both helpers guard against a missing control first. get() takes a string, so it returns AbstractControl or null, and the compiler makes us say what happens for a name the group does not have. That is also the reason the strong typing of a reactive form does not extend to these two functions: a typo in a string is not a typo the compiler can see.

Creation Process, DatePipe, and Modal Invocation

To continue, we are going to add the createOwner function that we call in the (ngSubmit) event:

createOwner = (ownerFormValue: any) => {
  if (this.ownerForm.valid)
    this.executeOwnerCreation(ownerFormValue);
}

It accepts the form value, checks if the form is valid, and if it is calls another private function passing the form value as a parameter.

That said, let’s create that private function:

private executeOwnerCreation = (ownerFormValue: any) => {
  const owner: OwnerForCreation = {
    name: ownerFormValue.name,
    dateOfBirth: this.datePipe.transform(ownerFormValue.dateOfBirth, 'yyyy-MM-dd') ?? '',
    address: ownerFormValue.address
  }
  const apiUrl = 'api/owner';
  this.repository.createOwner(apiUrl, owner)
  .subscribe({
    next: (own: Owner) => {
      const config: ModalOptions = {
        initialState: {
          modalHeaderText: 'Success Message',
          modalBodyText: `Owner: ${own.name} created successfully`,
          okButtonText: 'OK'
        }
      };

      this.bsModalRef = this.modal.show(SuccessModal, config);
      this.bsModalRef.content?.redirectOnOk.subscribe(() => this.redirectToOwnerList());
    }
  })

}

Here we create a new OwnerForCreation object that we are going to send to the API with our POST request. Notice that in this example using the date pipe is not restricted only to HTML files. Of course, to make it work inside a component file, we have to provide the DatePipe on the component itself:

import { DatePipe } from '@angular/common';

@Component({
  imports: [ReactiveFormsModule, BsDatepickerDirective, BsDatepickerInputDirective],
  providers: [DatePipe],
  ...
})

Two components in this whole series use the pipe in a class, this one and the update component in the next part, so it is provided where it is used rather than once for the whole application. transform() returns a string or null, and our interface asks for a string, which is why the call ends in ?? ''.

Once we create the ownerForCreation object, we create the apiUrl, and call the createOwner repository function. Now, since we are using the OwnerForCreation type, and not the Owner type, we have to modify the type inside the repository function:

public createOwner(route: string, owner: OwnerForCreation) {

Good. Let’s get back to our logic.

Inside the subscribe function, with the next property, we accept the success response from the API, create the initial state object for our success modal, and then show the modal by providing the SuccessModal component and the config object as parameters. Additionally, we use the bsModalRef object to subscribe to the redirectOnOk event that we emit from the success modal once we click the OK button.

There is no error branch here. The interceptor we registered in Angular Error Handling: HTTP Errors and Error Pages sees every failed request before the component does, so a failure needs no callback in this subscription. The POST action on the server is the one we built in the first half of this series.

Redirection

With this subscription, we are calling the redirectToOwnerList function. We call the same function by clicking on the Cancel button in our form. So, let’s add that function:

redirectToOwnerList = () => {
  this.router.navigate(['/owner/list']);
}

This is the familiar code for navigating back to the previous component. There is another way of doing this by importing the Location from the @angular/common and injecting it inside the component and then just calling the back() function on that injected property (location.back()). What you decide to use is totally up to you.

Now just modify our root CSS file (styles.css), to show <em> messages with the red color and the bold style and to wrap the inputs in the red color if they are invalid:

em {
  color: #e71515;
  font-weight: bold;
}

.ng-invalid.ng-touched {
  border-color: red;
}

Inspecting Results

Now, if we navigate to the create owner page, once we click inside each input element but leave it empty, we are going to see our error messages:

Input errors Angular Form Validation

Of course, we can test the max length validation as well. Typing 61 characters into the name field swaps in the message our own template holds for that rule, “Maximum allowed length is 60 characters.”, and leaves the Save button disabled until the value is short enough.

Once we populate all the fields and click the Save button, we are going to see the success modal message:

Owner created successfully Angular Form Validation

The API answers a successful POST with 201 Created, a Location header pointing at the new owner, and the created owner in the body. RFC 9110 defines that code as follows: “The 201 (Created) status code indicates that the request has been fulfilled and has resulted in one or more new resources being created.” That body is what the next callback receives, which is how the modal can greet the owner by name.

When we click the OK button, we will be redirected to the owner-list page and the new owner is going to be on the list.

How Do We Show an Error Modal for Other Statuses?

In the error interceptor, we are going to modify the handleOtherError function, which is the branch we left unfinished two parts ago:

const handleOtherError = (error: HttpErrorResponse) => {
  const config: ModalOptions = {
    initialState: {
      modalHeaderText: 'Error Message',
      modalBodyText: createErrorMessage(error),
      okButtonText: 'OK'
    }
  };

  modal.show(ErrorModal, config);
};

Of course, we have to add imports in the same file:

import { BsModalService, ModalOptions } from 'ngx-bootstrap/modal';

import { ErrorModal } from '../modals/error-modal/error-modal';

The interceptor also needs the service itself, injected at the top of the function beside the Router it already uses:

const modal = inject(BsModalService);

Now if an error other than 500 or 404 appears, we are going to show a modal message to the user. We can force an error, to test this behavior, by modifying the CreateOwner action in the Web API project:

[HttpPost]
public async Task<IActionResult> CreateOwner([FromBody] OwnerForCreationDto owner)
{
    return BadRequest("Bad request from the server while creating owner");
...

Which Angular Validators Ship Built In?

Validators is a static class in @angular/forms with eleven members. Eight of them check a value, one of them does nothing at all, and two combine other validators into one.

The checks are required, requiredTrue, email, min, max, minLength, maxLength and pattern. nullValidator always passes and exists so a slot can be filled with nothing. compose and composeAsync fold a list of validators into a single function.

Two details catch people out, and both are in the table below.

The error key is not always the method name. requiredTrue fails with the required key rather than its own, and minLength and maxLength fail with all-lowercase keys. That lower case is why our template asks for hasError('name', 'maxlength') and would show nothing at all if it asked for the camel-cased spelling.

Most validators skip empty values deliberately, so optional controls pass. maxLength is the exception and has no such guard.

ValidatorWhat it checksError key when it fails
Validators.requiredThe value is not emptyrequired: true
Validators.requiredTrueThe value is exactly truerequired: true
Validators.emailThe value matches Angular's email patternemail: true
Validators.min(n)The parsed number is not below nmin: { min, actual }
Validators.max(n)The parsed number is not above nmax: { max, actual }
Validators.minLength(n)The length is not below nminlength: { requiredLength, actualLength }
Validators.maxLength(n)The length is not above nmaxlength: { requiredLength, actualLength }
Validators.pattern(p)The value matches ppattern: { requiredPattern, actualValue }
Validators.nullValidatorNothing; it always passesNever fails
Validators.compose([...])Runs several sync validators as oneMerged keys from those that fail
Validators.composeAsync([...])Runs several async validators as oneMerged keys from those that fail

None of this replaces the server’s own checks. Our OwnerForCreationDto carries those same rules as data annotations, and we walk through that half in ModelState validation in ASP.NET Core Web API. When those rules grow past attributes, FluentValidation expresses them as validator classes instead.

Conclusion

By reading this post you have learned:

  • About the two validation types in Angular, and why we picked the reactive one
  • How to use reactive form validation in the HTML file
  • How to use reactive form validation in the component file
  • The way to create a new entity on the client-side
  • Which validators Angular ships, and which error key each one raises

Thank you for reading the post, hopefully, it was helpful to you.

In Angular PUT Request to an ASP.NET Core Web API, we are going to write the update part of the project, by sending the PUT request to our server.

Tested with Angular 22.1.5, Angular CLI 22.1.7, ngx-bootstrap 22.0.0, Bootstrap 5.3.8, TypeScript 6.0.3 and Node 24.