Data Annotations and ModelState

Put validation rules on the model with data annotations such as [Required], [StringLength] and [Range]; check ModelState.IsValid in the action; add your own errors with AddModelError; and show the messages with validation tag helpers.

Journeyman · ASP.NET Core · C# · Updated

After this lesson you can

  • Add validation rules to a model with data annotations, with their own error messages
  • Check ModelState.IsValid in a POST action, add custom errors with AddModelError, and show the form again
  • Show validation messages with asp-validation-for and asp-validation-summary

Key takeaways

  • Data annotations such as [Required], [StringLength(40)] and [Range(0.01, 1000)] put rules on a model's properties, each with an ErrorMessage that says what to do.
  • A nullable property such as int? is optional unless it's marked [Required].
  • When ModelState.IsValid is false, the POST action shows the form again with what the user typed, and saves nothing.
  • ModelState.AddModelError adds your own error for rules an attribute can't check, such as a product name that's already taken.
  • asp-validation-for shows one field's message beside it; asp-validation-summary="ModelOnly" shows the errors that belong to no single field.

Rules on the model

Data annotations are attributes from System.ComponentModel.DataAnnotations that state a rule for a property:

using System.ComponentModel.DataAnnotations;

namespace NorthwindWeb.Models;

public class Product
{
    public int ProductId { get; set; }

    [Required(ErrorMessage = "Enter the product's name.")]
    [StringLength(40, ErrorMessage = "A name can be at most 40 characters.")]
    [Display(Name = "Name")]
    public string ProductName { get; set; } = "";

    [Required(ErrorMessage = "Choose a category.")]
    [Display(Name = "Category")]
    public int? CategoryId { get; set; }

    [Range(0.01, 1000, ErrorMessage = "Enter a price from $0.01 to $1,000.")]
    [Display(Name = "Price")]
    public decimal? UnitPrice { get; set; }

    [Range(0, 10000, ErrorMessage = "Enter a number of units from 0 to 10,000.")]
    [Display(Name = "In stock")]
    public short? UnitsInStock { get; set; }
}
Annotation Rule
[Required] Must have a value. (A nullable property, such as int?, is optional unless marked [Required].)
[StringLength(40)] At most 40 characters; add MinimumLength = 2 for a minimum
[Range(0.01, 1000)] A number between the two, inclusive
[EmailAddress] Looks like an email address
[RegularExpression("...")] Matches a pattern
[Compare("Password")] Equals another property, such as a password and its confirmation
[Display(Name = "Price")] Not a rule: the label asp-for writes, and the name used in default messages

Every rule takes an ErrorMessage. Write it for the person filling in the form: what to do, not what went wrong.

Checking: ModelState.IsValid

Model binding checks the rules while it fills the model, and records each problem in ModelState. The action decides what to do:

[HttpPost("new")]
[ValidateAntiForgeryToken]
public IActionResult Create(Product product)
{
    if (productDao.NameExists(product.ProductName))
    {
        ModelState.AddModelError(nameof(Product.ProductName), "There's already a product with that name.");
    }

    if (!ModelState.IsValid)
    {
        ViewData["Categories"] = productDao.GetCategories();
        return View(product);
    }

    int newId = productDao.Add(product);
    return RedirectToAction(nameof(Details), new { id = newId });
}
  • ModelState.IsValid is false if any rule failed. Then the action shows the form again, with what the user typed, and saves nothing.
  • ModelState.AddModelError(field, message) adds an error of your own, for rules an attribute can't express: here, a name that's already taken needs the database. Pass the property's name to attach it to that field, or "" for an error about the whole form.
  • nameof(Product.ProductName) gives the text "ProductName", but the compiler checks it, so a renamed property can't leave a stale name behind.

Showing the messages

Two tag helpers show the errors on the form:

<form method="post" asp-action="Create">
    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
    <div class="mb-3">
        <label asp-for="ProductName" class="form-label"></label>
        <input asp-for="ProductName" class="form-control">
        <span asp-validation-for="ProductName" class="text-danger"></span>
    </div>
    <div class="mb-3">
        <label asp-for="UnitPrice" class="form-label"></label>
        <input asp-for="UnitPrice" class="form-control">
        <span asp-validation-for="UnitPrice" class="text-danger"></span>
    </div>
    <button type="submit" class="btn btn-primary">Add the product</button>
</form>
Tag helper Shows
<span asp-validation-for="ProductName"> That field's message, beside the field
<div asp-validation-summary="ModelOnly"> Errors that belong to no single field
<div asp-validation-summary="All"> Every error, as a list

The labels are empty in the view: asp-for fills them from [Display(Name = ...)].

Post the form with an empty name, no category and a price of 0, and it comes back with three messages:

Enter the product's name.
Choose a category.
Enter a price from $0.01 to $1,000.

Post a name that's already taken, such as Chai, and the custom error appears instead: There's already a product with that name.

Check yourself

3 questions to try on the lesson page.

So what

Rules on the model, ModelState.IsValid in the action, messages in the view: bad input comes back to the person who typed it, with a clear note, and never reaches the database.

Key terms

  • Data Annotations
  • ModelState
  • Model Validation
  • Validation Tag Helpers

Cheat sheets