Angular Reactive Forms validation with ASP.NET Core ProblemDetails means one FormGroup UX for both client rules and server field errors — so create/edit screens disable cleanly on submit, show RFC 9457-style errors, and never invent a second envelope per feature.
User submit
│
├─► client validators (immediate UX)
│
└─► POST/PUT API
│
▼
400 ProblemDetails { errors: { email: [...] } }
│
▼
map → control.setErrors / error summary
New to this → stay here. Server FluentValidation → API validation. Unhandled 500s → global exception handling.
Search intent for angular reactive forms validation asp.net core is how-to on the client binding side. This post does not re-teach FluentValidation registration.
Client validation vs server validation: trust boundaries
| Layer | Purpose | Trusted? |
|---|---|---|
| Angular validators | Fast feedback, enable/disable Submit | No — attacker bypasses UI |
| ASP.NET Core FluentValidation / DataAnnotations | Real enforcement + field errors | Yes |
| Global exception handler | Unexpected failures → safe ProblemDetails | Yes (for 500s) |
I keep client validators for required/email/minLength so users are not punished with a round-trip for empty fields. Business uniqueness (“NPI already registered”) stays server-side and returns 400/409 with a stable shape.
Contract habits for Angular + .NET teams: integration guide.
FormGroup patterns for API-backed create/edit screens
Prefer a typed form for API-backed screens:
interface PatientForm {
displayName: FormControl<string>;
email: FormControl<string>;
dateOfBirth: FormControl<string | null>;
}
readonly form = new FormGroup<PatientForm>({
displayName: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(200)],
}),
email: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email],
}),
dateOfBirth: new FormControl<string | null>(null),
});
Load edit screens with patchValue from GET DTOs — not by rebuilding the whole tree unless the shape changes. Keep raw API dates in one format (ISO) end-to-end to avoid silent validation mismatches.
Mapping ProblemDetails / RFC 9457 extensions to controls
Typical validation payload from ASP.NET Core:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": ["Email is already registered."],
"DisplayName": ["Display name must not be empty."]
},
"traceId": "00-abc..."
}
Mapper:
export interface ProblemDetails {
title?: string;
status?: number;
detail?: string;
errors?: Record<string, string[]>;
traceId?: string;
}
export function applyProblemDetailsToForm(
form: FormGroup,
problem: ProblemDetails,
): string[] {
const summary: string[] = [];
const errors = problem.errors ?? {};
for (const [key, messages] of Object.entries(errors)) {
const path = toCamelPath(key); // Email → email; Address.City → address.city
const control = form.get(path);
const message = messages[0] ?? 'Invalid value';
if (control) {
control.setErrors({ ...(control.errors ?? {}), server: message });
control.markAsTouched();
} else {
summary.push(message);
}
}
if (!Object.keys(errors).length && problem.title) {
summary.push(problem.title);
}
return summary;
}
function toCamelPath(serverKey: string): string {
return serverKey
.split('.')
.map((segment) => segment.length ? segment[0].toLowerCase() + segment.slice(1) : segment)
.join('.');
}
Clear server errors before resubmit so stale server keys do not stick after the user edits:
function clearServerErrors(control: AbstractControl): void {
if (control instanceof FormGroup) {
Object.values(control.controls).forEach(clearServerErrors);
return;
}
if (control.errors?.['server']) {
const { server, ...rest } = control.errors;
control.setErrors(Object.keys(rest).length ? rest : null);
}
}
Match server property names to FormControl paths deliberately. If the API uses email already (camelCase System.Text.Json), skip aggressive rewriting.
Cross-field rules and disable-while-submit
Cross-field (client):
export const dateRangeValidator: ValidatorFn = (group) => {
const start = group.get('start')?.value;
const end = group.get('end')?.value;
if (start && end && start > end) {
return { dateRange: true };
}
return null;
};
// form = new FormGroup({...}, { validators: [dateRangeValidator] });
Submit UX:
saving = signal(false);
formErrorSummary = signal<string[]>([]);
save(): void {
clearServerErrors(this.form);
this.formErrorSummary.set([]);
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.saving.set(true);
this.form.disable({ emitEvent: false });
this.api.createPatient(this.form.getRawValue()).pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
this.saving.set(false);
this.form.enable({ emitEvent: false });
}),
).subscribe({
next: (created) => this.router.navigate(['/patients', created.id]),
error: (err) => {
const problem = extractProblemDetails(err);
if (problem?.status === 400) {
this.formErrorSummary.set(applyProblemDetailsToForm(this.form, problem));
return;
}
// 409/500 → banner / toast with title + traceId, not control spam
this.formErrorSummary.set([problem?.title ?? 'Save failed']);
},
});
}
Use getRawValue() when you disable the form while saving so values still serialize. Prefer exhaustMap on template-driven click streams so double-clicks do not double POST (switchMap vs exhaustMap). Idempotent POST keys on the API still matter for retries (idempotency).
400 vs 422 habits with Angular interceptors
Most ASP.NET Core apps emit 400 for validation ProblemDetails. Some teams prefer 422. Pick one and teach the interceptor:
export function problemDetailsInterceptor(
req: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> {
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
// Attach parsed problem for feature code; do not toast every 400 globally
if (err.error && typeof err.error === 'object') {
(err as any).problemDetails = err.error as ProblemDetails;
}
return throwError(() => err);
}),
);
}
Rules I use:
- 400/422 with
errors→ feature maps to FormGroup; no global toast spam - 409 → banner with
title - 500 → generic message +
traceIdfor support (global exceptions) - Do not put JWT refresh logic in the same branch as validation mapping
Reusable error-display components and a11y error summaries
<div class="error-summary" role="alert" *ngIf="formErrorSummary().length">
<p>We could not save. Fix the following:</p>
<ul>
@for (msg of formErrorSummary(); track msg) {
<li>{{ msg }}</li>
}
</ul>
</div>
<label for="email">Email</label>
<input id="email" [formControl]="form.controls.email" aria-describedby="email-err" />
@if (form.controls.email.touched && form.controls.email.errors) {
<p id="email-err" class="field-error">
{{ form.controls.email.errors['server']
|| (form.controls.email.errors['email'] ? 'Enter a valid email.' : 'Email is required.') }}
</p>
}
Keep one visible summary for server messages that do not map to a control. Focus the summary on failed submit for keyboard users.
Testing FormGroup + HttpTestingController with ProblemDetails fixtures
it('maps ProblemDetails field errors onto controls', () => {
const fixture = TestBed.createComponent(PatientCreatePage);
fixture.detectChanges();
const page = fixture.componentInstance;
page.form.setValue({
displayName: 'Alex',
email: 'alex@example.com',
dateOfBirth: null,
});
page.save();
const req = httpMock.expectOne('/api/patients');
req.flush(
{
title: 'One or more validation errors occurred.',
status: 400,
errors: { Email: ['Email is already registered.'] },
},
{ status: 400, statusText: 'Bad Request' },
);
expect(page.form.controls.email.errors?.['server'])
.toBe('Email is already registered.');
expect(page.saving()).toBeFalse();
expect(page.form.enabled).toBeTrue();
});
Fixture the exact content-type your API uses (application/problem+json) if your extractor branches on headers.
Anti-patterns
- Swallowing 400s in an interceptor that only
console.logs — forms look “stuck” - Double toasts — global interceptor toasts every 400 and the page shows field errors
- Mutating controls inside
valueChangesloops without{ emitEvent: false }— CPU fans cry - Replacing the whole FormGroup on each server error — loses focus and dirty state
- Showing
exception.Messagefrom 500s in the form summary — information disclosure - Trusting only client validators because “the UI disables Submit”
Verification
- Empty required fields → client errors, no HTTP call
- Unique constraint failure → 400, email control shows server message, other fields intact
- Disable-while-submit prevents double POST in Network tab
- Unmapped server key appears in
role="alert"summary - 500 shows generic copy + traceId, not stack text
- Unit test with HttpTestingController fails if mapper breaks casing
If an interviewer asks
How do Angular forms consume ASP.NET Core validation errors?
Strong answer: One ProblemDetails contract with an errors dictionary. Map keys to FormControls via setErrors({ server }), clear server errors on edit/resubmit, keep client validators for UX only, and leave 500s to a global handler shape. Interceptors should parse — not toast every 400.
Related: FluentValidation / API validation · Global exception handling · Angular + .NET integration · exhaustMap submit
Nested FormGroups and array field errors
Server keys often look like Contacts[0].Email or Contacts[0].Email depending on serializer. Normalize before form.get:
function normalizeServerKey(key: string): string {
return key
.replace(/\[(\d+)\]/g, '.$1') // Contacts[0].Email → Contacts.0.Email
.split('.')
.map((s, i) => (i === 0 ? s[0].toLowerCase() + s.slice(1) : /^\d+$/.test(s) ? s : s[0].toLowerCase() + s.slice(1)))
.join('.');
}
For FormArray:
const contacts = this.form.controls.contacts; // FormArray<FormGroup>
// path contacts.0.email
If a server error cannot map, keep it in the summary — do not drop it.
Aligning FluentValidation property names with Angular
On the server, prefer explicit RuleFor names that match JSON:
RuleFor(x => x.Email).NotEmpty().WithName("email");
// or configure PropertyNameResolver for camelCase
Angular should not need a 200-line alias table. If you inherited PascalCase errors, centralize toCamelPath once.
Link the server setup: FluentValidation ProblemDetails — this page stays on the client mapper.
Optimistic UI vs server truth
For autosave screens:
- Debounce PATCH
- Apply server errors to the field that failed without wiping unrelated dirty controls
- On 409 conflict, reload or show concurrency banner — do not silently
patchValueover user keystrokes
if (problem.status === 409) {
this.conflictBanner.set(problem.title ?? 'This record changed. Reload and try again.');
return;
}
Interceptor vs page responsibility matrix
| Concern | Interceptor | Page/Form |
|---|---|---|
| Attach JWT | Yes | No |
| Parse problem+json | Optional helper | Consumes |
| Toast every error | No | Selective |
| setErrors on controls | No | Yes |
| Navigate on 401 after refresh fail | Yes | Rarely |
Keeping form mapping in the page (or a small FormProblemMapper service) avoids circular dependencies and mystery toasts.
Accessibility details that pass real audits
aria-invalid="true"when control shows error- Error text linked with
aria-describedby - Summary
role="alert"oraria-live="assertive"on submit failure - Do not rely on color alone — prefix “Error:” in text if design is icon-only
End-to-end submit checklist for each create/edit screen
- Client validators cover required UX fields
savingsignal disables button and formexhaustMapor disabled button prevents double POST- 400 maps to controls + summary
- 409/500 do not call
setErrorson random fields traceIdavailable in a “Copy support id” control for 500s- HttpTestingController tests cover happy + 400 paths
Practitioner note from healthcare admin UIs
Registration and prior-auth forms fail in production on business rules the client never knew (payer-specific). The FormGroup pattern above is what keeps nurses from seeing a raw JSON dump. If your API still returns { success:false, message:"..." } strings, fix the server envelope before inventing more Angular parsers.
Working with Minimal APIs validation the same way
Whether the server uses controllers or Minimal APIs, Angular should not care. If Minimal endpoints return the same ValidationProblem / ProblemDetails shape, reuse applyProblemDetailsToForm. If a team invents { errorCode, fields } for Minimal only, stop and align — see global exception handling for why one envelope matters.
Dirty-checking and navigate-away guards
canDeactivate = () => {
if (!this.form.dirty || this.saving()) return true;
return confirm('Discard unsaved changes?');
};
Clear dirty after successful save with markAsPristine(). Server errors should leave the form dirty so users do not lose work.
Partial updates (PATCH) and field-level errors
For PATCH, only send changed controls; map errors only onto those paths. If the server returns an error for a field you did not send (business rule), show it in the summary and optionally get(path)?.setErrors.
Performance: avoid re-creating validators on each change detection
Define validator functions as module-level constants. Rebuilding new FormGroup on every @Input change destroys UX; patchValue instead.