Loading...

ASP.NET Core Validation Errors: Normalize ModelState to { field, message }[]

Normalize ASP.NET Core ModelState errors into a simple { field, message }[] format using InvalidModelStateResponseFactory, so clients can render validation consistently.

With [ApiController], ASP.NET Core automatically validates incoming models. If the request body is invalid, the framework returns 400 Bad Request with a validation payload. That default response is usable, but the errors structure is typically a dictionary (field -> string[]), which often leads to extra client-side parsing.

A cleaner approach is to normalize validation errors into a single, predictable format such as { field, message }[]. This makes UI code simpler (forms, toasts, field highlights) because the client always loops the same array.


Goal


Default ModelState Response (What You Get Today)

With [ApiController], invalid requests often produce a payload like this (simplified):


{
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Email": [ "The Email field is not a valid e-mail address." ],
    "Guests": [ "The field Guests must be between 1 and 10." ]
  }
}

The issue is not the status code—it’s the shape: clients usually need to flatten the dictionary before they can render errors consistently.


Target Shape (Normalized)

Instead of a dictionary, return a simple list:


{
  "message": "Validation failed.",
  "code": "VALIDATION_ERROR",
  "errors": [
    { "field": "email", "message": "The Email field is not a valid e-mail address." },
    { "field": "guests", "message": "The field Guests must be between 1 and 10." }
  ]
}

Notice the field names are camelCase. This tiny detail reduces UI mapping work in many apps.


Step 1: Define a Small Error DTO


public sealed record FieldError(string Field, string Message);

Step 2: Flatten ModelState into { field, message }[] (with a small C# 12 touch)

ModelState stores errors as a dictionary where each key can have multiple messages. The helper below turns it into a flat list. While doing that, we also convert the field name to camelCase using: JsonNamingPolicy.CamelCase.ConvertName(...).


using System.Text.Json;
using Microsoft.AspNetCore.Mvc.ModelBinding;

public static class ModelStateExtensions
{
    public static List<FieldError> ToFieldErrors(this ModelStateDictionary modelState)
    {
        var list = new List<FieldError>();

        foreach (var kvp in modelState)
        {
            // Small C# 12 touch: normalize field names to camelCase
            // (Works best for simple keys like "Email", "Guests")
            var field = JsonNamingPolicy.CamelCase.ConvertName(kvp.Key);

            foreach (var err in kvp.Value.Errors)
            {
                // If ErrorMessage is empty, fallback to Exception message (rare, but possible).
                var msg = !string.IsNullOrWhiteSpace(err.ErrorMessage)
                    ? err.ErrorMessage
                    : err.Exception?.Message ?? "Invalid value.";

                list.Add(new FieldError(field, msg));
            }
        }

        return list;
    }
}

Note: if your keys include dotted paths (e.g. request.Email or items[0].Name), you may want to camelCase per segment. For many APIs, simple property names are enough and this stays nicely minimal.


Step 3: Override the Global Validation Response (Program.cs)

The cleanest global hook is ConfigureApiBehaviorOptions. When model validation fails, ASP.NET Core calls InvalidModelStateResponseFactory. We return our normalized payload there.


using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            var errors = context.ModelState.ToFieldErrors();

            var payload = new
            {
                message = "Validation failed.",
                code = "VALIDATION_ERROR",
                errors
            };

            return new BadRequestObjectResult(payload);
        };
    });

var app = builder.Build();

app.MapControllers();
app.Run();

From this point on, every invalid request automatically returns the same normalized error list—without writing repeated if (!ModelState.IsValid) blocks in controllers.


Example: DTO + Endpoint

With [ApiController], you don’t need to manually check ModelState. If the model is invalid, ASP.NET Core returns 400 automatically using our factory.


using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;

public sealed class CreateReservationRequest
{
    [Required, EmailAddress]
    public string Email { get; set; } = "";

    [Range(1, 10)]
    public int Guests { get; set; }
}

[ApiController]
[Route("api/reservations")]
public sealed class ReservationsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create([FromBody] CreateReservationRequest request)
    {
        // If invalid: 400 is returned automatically (via InvalidModelStateResponseFactory).
        return Ok(new { message = "Created" });
    }
}

Client Side (Now Very Simple)

Since validation errors always arrive as a list, the client can render them consistently:


const res = await fetch("/api/reservations", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "not-an-email", guests: 100 })
});

if (!res.ok) {
  const payload = await res.json();

  // payload.errors is always [{ field, message }]
  for (const e of payload.errors || []) {
    console.log(e.field, e.message);
  }
}

Common Improvements


TL;DR

  • [ApiController] already validates models and returns 400 automatically.
  • Use InvalidModelStateResponseFactory to customize that response globally.
  • Flatten ModelState into { field, message }[] to simplify client-side code.
  • Use JsonNamingPolicy.CamelCase.ConvertName to output camelCase field names by default.