refactor(forms): type built-in getError results

Add overloads for built-in validation error keys so callers get precise error payload types from getError.

This enables signal forms template patterns like:

```html
@if (login.getError('minLength'); as minLengthError) {
  <div>Login should be {{ minLengthError.minLength }} characters</div>
}
```

(cherry picked from commit 45e8fb5d6c)
This commit is contained in:
cexbrayat
2026-05-26 22:28:10 +02:00
committed by Pawel Kozlowski
parent e3e25b5a53
commit e81c7e8466
5 changed files with 79 additions and 2 deletions
@@ -11,6 +11,7 @@ import { FormControlStatus } from '@angular/forms';
import * as i0 from '@angular/core';
import { Injector } from '@angular/core';
import { Signal } from '@angular/core';
import { StandardSchemaV1 } from '@standard-schema/spec';
import { WritableSignal } from '@angular/core';
// @public
@@ -138,6 +138,10 @@ export type FieldContext<TValue, TPathKind extends PathKind = PathKind.Root> = T
export interface FieldState<TValue, TKey extends string | number = string | number> extends ReadonlyFieldState<TValue, TKey> {
readonly controlValue: WritableSignal<TValue>;
readonly fieldTree: FieldTree<unknown, TKey>;
getError<K extends NgValidationError['kind']>(kind: K): (Extract<NgValidationError, {
kind: K;
}> & ValidationError.WithFieldTree) | undefined;
// (undocumented)
getError(kind: string): ValidationError.WithFieldTree | undefined;
markAsDirty(): void;
markAsTouched(options?: MarkAsTouchedOptions): void;
+4 -1
View File
@@ -9,7 +9,7 @@
import {Injector, Signal, WritableSignal} from '@angular/core';
import {AbstractControl} from '@angular/forms';
import type {FormField} from '../directive/form_field';
import type {MetadataKey, ValidationError} from './rules';
import type {MetadataKey, NgValidationError, ValidationError} from './rules';
/**
* Symbol used to retain generic type information when it would otherwise be lost.
@@ -545,6 +545,9 @@ export interface FieldState<
* @param kind The kind of error (e.g. 'required', 'min').
* @returns The first matching error, or `undefined` if none.
*/
getError<K extends NgValidationError['kind']>(
kind: K,
): (Extract<NgValidationError, {kind: K}> & ValidationError.WithFieldTree) | undefined;
getError(kind: string): ValidationError.WithFieldTree | undefined;
/**
+5 -1
View File
@@ -25,7 +25,7 @@ import {
REQUIRED,
IS_ASYNC_VALIDATION_RESOURCE,
} from '../api/rules/metadata';
import type {ValidationError} from '../api/rules/validation/validation_errors';
import type {NgValidationError, ValidationError} from '../api/rules/validation/validation_errors';
import type {
DisabledReason,
FieldContext,
@@ -258,6 +258,10 @@ export class FieldNode implements FieldState<unknown> {
return this.metadataState.get(key);
}
getError<K extends NgValidationError['kind']>(
kind: K,
): (Extract<NgValidationError, {kind: K}> & ValidationError.WithFieldTree) | undefined;
getError(kind: string): ValidationError.WithFieldTree | undefined;
getError(kind: string): ValidationError.WithFieldTree | undefined {
return this.errors().find((e) => e.kind === kind);
}
@@ -14,17 +14,28 @@ import {
applyEach,
debounce,
disabled,
EmailValidationError,
form,
FormField,
hidden,
MaxDateValidationError,
MaxLengthValidationError,
MaxValidationError,
MinDateValidationError,
MinLengthValidationError,
MinValidationError,
NativeInputParseError,
PatternValidationError,
readonly,
required,
requiredError,
RequiredValidationError,
Schema,
schema,
SchemaOrSchemaFn,
SchemaPath,
SchemaPathTree,
StandardSchemaValidationError,
validate,
validateTree,
ValidationError,
@@ -79,6 +90,60 @@ describe('FieldNode', () => {
expect(f.c).toBeUndefined();
});
it('infers built-in error types from getError', () => {
const f = form(signal({a: 1}), {injector: TestBed.inject(Injector)});
const field = f.a();
{
const error = field.getError('required');
let t: (RequiredValidationError & ValidationError.WithFieldTree) | undefined = error;
}
{
const error = field.getError('min');
let t: (MinValidationError & ValidationError.WithFieldTree) | undefined = error;
}
{
const error = field.getError('minDate');
let t: (MinDateValidationError & ValidationError.WithFieldTree) | undefined = error;
}
{
const error = field.getError('max');
let t: (MaxValidationError & ValidationError.WithFieldTree) | undefined = error;
}
{
const error = field.getError('maxDate');
let t: (MaxDateValidationError & ValidationError.WithFieldTree) | undefined = error;
}
{
const error = field.getError('minLength');
let t: (MinLengthValidationError & ValidationError.WithFieldTree) | undefined = error;
}
{
const error = field.getError('maxLength');
let t: (MaxLengthValidationError & ValidationError.WithFieldTree) | undefined = error;
}
{
const error = field.getError('pattern');
let t: (PatternValidationError & ValidationError.WithFieldTree) | undefined = error;
}
{
const error = field.getError('email');
let t: (EmailValidationError & ValidationError.WithFieldTree) | undefined = error;
}
{
const error = field.getError('standardSchema');
let t: (StandardSchemaValidationError & ValidationError.WithFieldTree) | undefined = error;
}
{
const error = field.getError('parse');
let t: (NativeInputParseError & ValidationError.WithFieldTree) | undefined = error;
}
{
const error = field.getError('custom');
let t: ValidationError.WithFieldTree | undefined = error;
}
});
it('can get a child inside of a computed', () => {
const f = form(
signal({