← Back to Articles         
How do you display ASP.NET Core validation errors in Angular forms?

How do you display ASP.NET Core validation errors in Angular forms?

Published on 02 Oct 2026     11 min read Angular
Error Handling

How Do You Display ASP.NET Core Validation Errors in Angular Forms?

When an Angular form sends invalid data to an ASP.NET Core Web API, ASP.NET Core can return 400 Bad Request with field-level validation errors. Angular can read those errors and attach them to the corresponding form controls.

This gives the user messages such as:

Product Name
[             ]
Product name is required.

Price
[-100         ]
Price must be greater than 0.

1. What Are ASP.NET Core Validation Errors?

Consider this DTO:

public class CreateProductDto
{
    [Required(ErrorMessage = "Product name is required.")]
    public string Name { get; set; } = string.Empty;

    [Range(1, 1000000,
        ErrorMessage = "Price must be greater than 0.")]
    public decimal Price { get; set; }
}

If Angular sends:

{
  "name": "",
  "price": -100
}

ASP.NET Core validation fails.

With [ApiController], ASP.NET Core automatically returns a 400 Bad Request response containing validation information.

A typical response looks like:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": [
      "Product name is required."
    ],
    "Price": [
      "Price must be greater than 0."
    ]
  }
}

Angular needs to read:

error.error.errors

and display those messages.


2. Full Simple Sample Program

We'll use an Angular Reactive Form.

ASP.NET Core — CreateProductDto.cs

using System.ComponentModel.DataAnnotations;

public class CreateProductDto
{
    [Required(ErrorMessage = "Product name is required.")]
    public string Name { get; set; } = string.Empty;

    [Range(
        1,
        1000000,
        ErrorMessage = "Price must be greater than 0."
    )]
    public decimal Price { get; set; }
}

ASP.NET Core — ProductsController.cs

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(CreateProductDto product)
    {
        return Ok(new
        {
            message = "Product created successfully."
        });
    }
}

Angular

product.ts

export interface CreateProduct {
  name: string;
  price: number;
}

validation-error.ts

This represents ASP.NET Core's validation response.

export interface ValidationErrorResponse {

  type?: string;

  title?: string;

  status: number;

  errors: {
    [key: string]: string[];
  };

}

product.service.ts

import { inject, Injectable } from '@angular/core';

import {
  HttpClient
} from '@angular/common/http';

import {
  Observable
} from 'rxjs';

import {
  CreateProduct
} from './product';

@Injectable({
  providedIn: 'root'
})
export class ProductService {

  private http = inject(HttpClient);

  private apiUrl =
    'https://localhost:7001/api/products';

  createProduct(
    product: CreateProduct
  ): Observable<any> {

    return this.http.post(
      this.apiUrl,
      product
    );

  }

}

products.ts

import {
  Component,
  inject
} from '@angular/core';

import {
  ReactiveFormsModule,
  FormBuilder,
  Validators
} from '@angular/forms';

import {
  HttpErrorResponse
} from '@angular/common/http';

import {
  ProductService
} from './product.service';

import {
  ValidationErrorResponse
} from './validation-error';

@Component({
  selector: 'app-products',
  standalone: true,
  imports: [
    ReactiveFormsModule
  ],
  templateUrl: './products.html'
})
export class Products {

  private fb = inject(FormBuilder);

  private productService =
    inject(ProductService);

  message = '';

  productForm = this.fb.nonNullable.group({

    name: [
      '',
      Validators.required
    ],

    price: [
      0,
      [
        Validators.required,
        Validators.min(1)
      ]
    ]

  });

  save() {

    this.message = '';

    if (this.productForm.invalid) {

      this.productForm.markAllAsTouched();

      return;

    }

    this.productService
      .createProduct(
        this.productForm.getRawValue()
      )
      .subscribe({

        next: () => {

          this.message =
            'Product created successfully.';

          this.productForm.reset();

        },

        error: (error: HttpErrorResponse) => {

          if (
            error.status === 400 &&
            error.error?.errors
          ) {

            this.setServerValidationErrors(
              error.error
            );

          }

        }

      });

  }

  private setServerValidationErrors(
    response: ValidationErrorResponse
  ) {

    Object.keys(response.errors)
      .forEach(fieldName => {

        const controlName =
          fieldName.toLowerCase();

        const control =
          this.productForm.get(controlName);

        if (control) {

          control.setErrors({

            ...control.errors,

            serverError:
              response.errors[fieldName].join(' ')

          });

        }

      });

  }

}

products.html

<h2>Create Product</h2>

<form
  [formGroup]="productForm"
  (ngSubmit)="save()">

  <div>

    <label>Product Name</label>

    <input
      type="text"
      formControlName="name">

    @if (
      productForm.controls.name.touched &&
      productForm.controls.name.hasError('required')
    ) {

      <div>
        Product name is required.
      </div>

    }

    @if (
      productForm.controls.name.hasError('serverError')
    ) {

      <div>
        {{
          productForm.controls.name
            .getError('serverError')
        }}
      </div>

    }

  </div>

  <br>

  <div>

    <label>Price</label>

    <input
      type="number"
      formControlName="price">

    @if (
      productForm.controls.price.touched &&
      productForm.controls.price.hasError('required')
    ) {

      <div>
        Price is required.
      </div>

    }

    @if (
      productForm.controls.price.touched &&
      productForm.controls.price.hasError('min')
    ) {

      <div>
        Price must be greater than 0.
      </div>

    }

    @if (
      productForm.controls.price.hasError('serverError')
    ) {

      <div>
        {{
          productForm.controls.price
            .getError('serverError')
        }}
      </div>

    }

  </div>

  <br>

  <button type="submit">
    Save
  </button>

</form>

@if (message) {

  <p>
    {{ message }}
  </p>

}

3. How Does It Work?

The complete flow is:

Angular Form
     ↓
User clicks Save
     ↓
Client-side validation
     ↓
POST /api/products
     ↓
ASP.NET Core
     ↓
DTO validation
     ↓
Invalid
     ↓
400 Bad Request
     ↓
ValidationProblemDetails
     ↓
Angular HttpErrorResponse
     ↓
error.error.errors
     ↓
setErrors()
     ↓
Display error under corresponding field

4. Angular Client-Side Validation

We already have Angular validation:

name: [
  '',
  Validators.required
]

and:

price: [
  0,
  [
    Validators.required,
    Validators.min(1)
  ]
]

This gives immediate feedback before the API call.

For example:

User enters price = -10

        ↓

Angular Validators.min(1)

        ↓

Display:
"Price must be greater than 0."

But Angular validation alone is not sufficient.


5. ASP.NET Core Server-Side Validation

The API also validates:

[Required]
public string Name { get; set; }

[Range(1, 1000000)]
public decimal Price { get; set; }

This is essential because someone could bypass Angular and call your API directly:

Postman
   ↓
ASP.NET Core API

Therefore:

Angular validation
       ↓
User experience

ASP.NET Core validation
       ↓
Authoritative validation

You should normally have both.


6. Receiving the Error in Angular

When the API returns 400:

error: (error: HttpErrorResponse) => {

  if (
    error.status === 400 &&
    error.error?.errors
  ) {

    this.setServerValidationErrors(
      error.error
    );

  }

}

There are three important properties:

error.status

contains:

400

while:

error.error

contains the API response body.

And:

error.error.errors

contains the field validation errors.


7. Understanding errors

Suppose ASP.NET Core returns:

{
  "errors": {
    "Name": [
      "Product name is required."
    ],
    "Price": [
      "Price must be greater than 0."
    ]
  }
}

This:

Object.keys(response.errors)

returns:

Name
Price

We iterate through them:

Object.keys(response.errors)
  .forEach(fieldName => {
  });

8. Find the Angular Form Control

ASP.NET Core may return:

Name

while our Angular control is:

name

So this simple example converts it:

const controlName =
  fieldName.toLowerCase();

Then:

const control =
  this.productForm.get(controlName);

For:

Name

Angular finds:

productForm.controls.name

For larger applications, don't depend only on toLowerCase(). Nested DTO paths such as Address.PostalCode require a proper field-name mapping strategy.


9. setErrors() — Important Part

This is the main concept.

Angular's AbstractControl has:

setErrors()

We attach the ASP.NET Core error to the corresponding control:

control.setErrors({

  serverError:
    response.errors[fieldName].join(' ')

});

Now the control contains an error named:

serverError

Conceptually:

name FormControl
    |
    +-- errors
          |
          +-- serverError
                 |
                 +-- "Product name is required."

10. Why Preserve Existing Errors?

Notice that the example uses:

control.setErrors({

  ...control.errors,

  serverError:
    response.errors[fieldName].join(' ')

});

Instead of simply:

control.setErrors({
  serverError: message
});

Why?

Because the control might already contain:

{
  required: true
}

If you replace the whole error object, you could lose existing validation errors.

Using:

...control.errors

preserves them.

The resulting errors could be:

{
  required: true,
  serverError: 'Product name is required.'
}

11. Display the Server Error in HTML

Check:

hasError('serverError')

Example:

@if (
  productForm.controls.name.hasError('serverError')
) {

  <div>
    {{
      productForm.controls.name
        .getError('serverError')
    }}
  </div>

}

If ASP.NET Core returned:

Product name is required.

the user sees:

Product Name
[                    ]
Product name is required.

12. Multiple Validation Messages for One Field

ASP.NET Core can potentially return:

{
  "Name": [
    "Product name is required.",
    "Product name cannot exceed 100 characters."
  ]
}

Our example uses:

response.errors[fieldName].join(' ')

Result:

Product name is required. Product name cannot exceed 100 characters.

Then we store it as one server error:

control.setErrors({
  serverError: message
});

13. Another Simpler Approach

You don't necessarily have to attach errors using setErrors().

You can keep all server errors in an object.

For example:

serverErrors: {
  [key: string]: string[]
} = {};

Then:

error: (error: HttpErrorResponse) => {

  if (error.status === 400) {

    this.serverErrors =
      error.error?.errors ?? {};

  }

}

And display:

@if (serverErrors['Name']) {

  @for (
    message of serverErrors['Name'];
    track message
  ) {

    <div>
      {{ message }}
    </div>

  }

}

This is simpler.

However, using:

control.setErrors()

integrates the server error directly into Angular's Reactive Forms validation state.


14. Which Approach Is Better?

For a Reactive Form, I generally prefer:

control.setErrors({
  serverError: ...
});

because the server validation becomes part of the form-control state.

You can then use:

control.invalid
control.errors
control.hasError('serverError')

just like normal Angular validation.


15. Important Issue: Clear Server Error When User Edits

Suppose the API says:

Product code already exists.

You attach:

serverError

to the control.

Then the user changes the product code.

The old server error may no longer be valid.

A common approach is to remove the server error when the value changes.

For example:

this.productForm.controls.name
  .valueChanges
  .subscribe(() => {

    const control =
      this.productForm.controls.name;

    if (control.hasError('serverError')) {

      const errors = {
        ...control.errors
      };

      delete errors['serverError'];

      control.setErrors(
        Object.keys(errors).length
          ? errors
          : null
      );

    }

  });

Conceptually:

API:
"Product name already exists."
        ↓
Display server error
        ↓
User changes Name
        ↓
Old server error no longer relevant
        ↓
Remove serverError

This is especially useful for business validation.


16. What About Non-Field Validation Errors?

Not every validation error belongs to one field.

For example:

End date must be greater than start date.

or:

The selected product cannot be deleted because
it has active orders.

These are better displayed as a form-level error.

For example:

formError = '';

Then:

this.formError =
  'End date must be greater than start date.';

HTML:

@if (formError) {

  <div>
    {{ formError }}
  </div>

}

So you can have:

Field-specific errors
        ↓
FormControl.setErrors()

Form/business errors
        ↓
Form-level message

17. What About the Global Error Interceptor?

Earlier we handled errors globally using an interceptor.

For validation errors, the interceptor generally should not swallow the 400.

For example:

catchError((error: HttpErrorResponse) => {

  if (error.status === 401) {

    // Global authentication handling

  }

  if (error.status === 403) {

    // Global authorization handling

  }

  if (error.status >= 500) {

    // Global server-error handling

  }

  return throwError(() => error);

})

Then the component receives the original 400:

error: (error: HttpErrorResponse) => {

  if (error.status === 400) {

    this.setServerValidationErrors(
      error.error
    );

  }

}

This is a clean separation.


Recommended Architecture

                    ASP.NET Core
                         |
              ValidationProblemDetails
                         |
                    400 Response
                         ↓
               Angular Interceptor
                         |
                  Pass 400 through
                         ↓
                 Product Component
                         |
                Read errors object
                         ↓
          ┌──────────────┴──────────────┐
          ↓                             ↓
    Field errors                  Form errors
          ↓                             ↓
 FormControl.setErrors()         General message
          ↓
 Display below input

For other statuses:

401 → Global authentication handling

403 → Global access-denied handling

400 → Form/component validation handling

404 → Usually resource-specific handling

500 → Global server-error handling

Key Points

  • ASP.NET Core [ApiController] automatically returns 400 for standard model-validation failures.
  • The response commonly uses ValidationProblemDetails.
  • Angular receives the response through HttpErrorResponse.
  • HTTP status:
error.status
  • Response body:
error.error
  • Validation dictionary:
error.error.errors
  • Use FormControl.setErrors() to attach backend validation errors to Angular form controls.
  • Use a custom key such as:
serverError
  • Display it using:
control.hasError('serverError')
  • Preserve existing client-side errors when adding server errors.
  • Clear stale server errors when the user changes the relevant field.
  • Use Angular validation for fast UX feedback.
  • Use ASP.NET Core validation as the authoritative validation layer.
  • Keep 400 validation handling close to the relevant form rather than converting every 400 into a generic global error.

Interview Questions and Answers

1. How do you display ASP.NET Core validation errors in an Angular form?

I read the validation response from HttpErrorResponse, extract error.error.errors, map each backend field to its Angular FormControl, and attach the error using setErrors().

control.setErrors({
  serverError: message
});

2. Where are ASP.NET Core validation errors available in Angular?

Typically:

error.error.errors

For example:

{
  Name: ['Product name is required.'],
  Price: ['Price must be greater than 0.']
}

3. What does setErrors() do?

setErrors() manually assigns validation errors to an Angular form control.

control.setErrors({
  serverError: 'Product name already exists.'
});

The control becomes invalid and the error can be displayed in the template.


4. How do you display the server error?

@if (control.hasError('serverError')) {
  {{ control.getError('serverError') }}
}

5. Should Angular validation replace ASP.NET Core validation?

No.

Angular validation
    ↓
Fast user feedback

ASP.NET Core validation
    ↓
Authoritative server validation

A client can be bypassed, so the backend must validate requests independently.


6. Should 400 validation errors be handled globally?

Usually the global interceptor should allow validation 400 responses to propagate to the relevant component.

return throwError(() => error);

The component then knows exactly where and how to display each validation error.


7. How do you handle multiple validation errors?

ASP.NET Core may return arrays:

{
  Name: [
    'Name is required.',
    'Name cannot exceed 100 characters.'
  ]
}

You can display each message separately or combine them:

const message =
  errors['Name'].join(' ');

8. What is a good production approach?

Use:

Angular Validators
        +
ASP.NET Core DTO validation
        +
Consistent API validation response
        +
FormControl.setErrors()

This provides immediate client-side feedback while preserving authoritative backend validation.

Interview Quick Answer

How do you display ASP.NET Core validation errors in Angular forms?

When ASP.NET Core returns 400 Bad Request with validation errors, I read error.error.errors from the Angular HttpErrorResponse. I map each server field to the corresponding Reactive Form control and use control.setErrors({ serverError: message }). The template then displays the error using hasError('serverError') and getError('serverError').

← Back to Articles