Custom validation functions for complex validation logic that goes beyond built-in validators.

Overview

ng-forge supports four types of custom validators:

  1. Declarative HTTP (type: 'http') - Validate against an HTTP endpoint with no function registration
  2. CustomValidator - Synchronous validators with access to FieldContext
  3. AsyncCustomValidator - Async validators using Angular's resource API
  4. HttpCustomValidator (function-based) - HTTP validators with full programmatic control

Key Principle: Validators should focus on validation logic, NOT presentation. Return only the error kind and configure messages at field level for proper i18n support.

Live Demo

Try the interactive example below to see both expression-based and function-based validators in action:

Material Material
Loading live example

Message Resolution

Error messages are resolved per error kind in this order:

  1. Field-level validationMessages[kind] (highest priority - per-field customization)
  2. Form-level defaultValidationMessages[kind] (fallback for common messages)
  3. The error's own message property (final fallback, used when the validator attaches one)

If none of these produce a message, a console warning is logged and the error is not displayed.

Recommendation: Prefer configured messages over validator-returned ones. Returning only the error kind and configuring messages at the field or form level keeps validation logic separate from presentation and supports i18n.

You can define messages at the form level for common validation errors:

{
  defaultValidationMessages: {
    noSpaces: 'Spaces are not allowed',
    passwordMismatch: 'Passwords must match'
  },
  fields: [/* ... */]
}

Two Validator Patterns

ng-forge supports two patterns for custom validators:

  1. Function-based - Register reusable validator functions (best for complex logic, reusability)
  2. Expression-based - Inline JavaScript expressions (best for simple, one-off validations)

Function-based validators have two interchangeable authoring forms:

  • Registered (functionName): JSON-serializable; safe to ship in configs loaded from APIs, OpenAPI, or databases.
  • Inline (fn): code-only, type-safe; skips the registry round-trip but cannot survive JSON serialization. fn and functionName are mutually exclusive: TypeScript rejects setting both, and at runtime an explicit warning is logged before the inline form wins.

The same fn / asyncFn alternative is available on conditions and derivations; see Inline functions vs registered names below.

Expression-Based Validators

For simple validation logic, use inline JavaScript expressions without registering functions.

Basic Example

{
  key: 'confirmPassword',
  type: 'input',
  value: '',
  validators: [{
    type: 'custom',
    expression: 'fieldValue === formValue.password',
    kind: 'passwordMismatch',
  }],
  validationMessages: {
    passwordMismatch: 'Passwords must match',
  },
}

How it works:

  • fieldValue - Current field's value
  • formValue - Entire form value object
  • Expression returns true = validation passes
  • Expression returns false = validation fails with the specified kind

Available Context

Expression-based validators have access to:

  • fieldValue - Current field value
  • formValue - Complete form value object (e.g., formValue.password, formValue.email)
  • fieldPath - Current field path
  • Custom functions registered in customFnConfig.customFunctions

Safe Member Access

Built-in null/undefined handling: Member access is safe by default - no manual null checks needed!

// ✅ Works safely even when nested values are null/undefined
{
  expression: 'fieldValue !== formValue.user.profile.firstName',
  kind: 'invalidNested',
}

// ❌ Unnecessary - Don't do this
{
  expression: '!formValue.user || !formValue.user.profile || !formValue.user.profile.firstName || fieldValue !== formValue.user.profile.firstName',
  kind: 'invalidNested',
}

// ✅ Better - Safe by default
{
  expression: '!formValue.user.profile.firstName || fieldValue !== formValue.user.profile.firstName',
  kind: 'invalidNested',
}

Accessing properties on null or undefined returns undefined instead of throwing errors, making expressions cleaner and more maintainable.

Common Expression Patterns

Password confirmation:

{
  expression: 'fieldValue === formValue.password',
  kind: 'passwordMismatch',
}

Date range validation:

{
  expression: 'fieldValue > formValue.startDate',
  kind: 'endDateBeforeStart',
}

Conditional required:

{
  expression: 'formValue.requiresApproval ? fieldValue?.length > 0 : true',
  kind: 'approvalRequired',
}

Numeric comparison:

{
  expression: 'fieldValue >= formValue.minAge && fieldValue <= formValue.maxAge',
  kind: 'ageOutOfRange',
}

Deeply nested field validation:

{
  // Safe to access deeply nested properties
  expression: 'fieldValue.toLowerCase() !== formValue.user.address.city.toLowerCase()',
  kind: 'invalidAddress',
}

Security

Expressions use secure AST-based parsing. Only safe JavaScript operations are allowed.

Function-Based Validators

Best for reusable validation logic on the field's own value.

Basic Example

import type { CustomValidator } from '@ng-forge/dynamic-forms';

// ✅ RECOMMENDED: Return only kind
const noSpaces: CustomValidator = (ctx) => {
  const value = ctx.value();
  if (typeof value === 'string' && value.includes(' ')) {
    return { kind: 'noSpaces' }; // No hardcoded message
  }
  return null;
};

// Register and configure message
const config = {
  fields: [
    {
      key: 'username',
      type: 'input',
      validators: [{ type: 'custom', functionName: 'noSpaces' }],
      validationMessages: {
        noSpaces: 'Spaces are not allowed', // Or Observable/Signal for i18n
      },
    },
  ],
  customFnConfig: {
    validators: {
      noSpaces,
    },
  },
};

Inline Alternative (fn)

For code-only projects, you can skip the registry round-trip and pass the validator directly:

import { CustomValidator } from '@ng-forge/dynamic-forms';

const noSpaces: CustomValidator = (ctx) => {
  const value = ctx.value();
  return typeof value === 'string' && value.includes(' ') ? { kind: 'noSpaces' } : null;
};

const config = {
  fields: [
    {
      key: 'username',
      type: 'input',
      // No customFnConfig.validators entry required — the function lives on the validator config.
      validators: [{ type: 'custom', fn: noSpaces }],
      validationMessages: { noSpaces: 'Spaces are not allowed' },
    },
  ],
};

fn and functionName are mutually exclusive (XOR at the type level). The inline form is not JSON-serializable; for configs loaded from APIs, OpenAPI, or databases, stick to functionName.

See also: the same XOR pattern applies to conditions and derivations. See Configuration for customFnConfig setup and AI Integration (MCP) for the MCP server, which emits configs using functionName exclusively.

FieldContext API

Custom validator functions receive Angular's raw FieldContext:

  • ctx.value() - Current field value (signal)
  • ctx.state - Field state (errors, touched, dirty, etc.)
  • ctx.fieldTree - The current field

FieldContext also exposes valueOf(path) and stateOf(path), but these take a FieldPath object from Angular's schema path tree, not a string. Custom validator functions have no access to path objects, so they cannot use valueOf to read other fields. For cross-field rules, use an expression-based validator (which can read formValue) or a form-level schema.

Cross-Field Validation

Use an expression-based validator that reads formValue to compare against other fields:

// Note: Custom validators return only 'kind'. Built-in validators (min, max, etc.)
// automatically include params for interpolation (e.g., {{min}}, {{max}}, etc.)
const config = {
  fields: [
    { key: 'minAge', type: 'input', value: 0 },
    {
      key: 'maxAge',
      type: 'input',
      value: 0,
      validators: [
        {
          type: 'custom',
          expression: 'fieldValue > formValue.minAge',
          kind: 'notGreaterThanMin',
        },
      ],
      validationMessages: {
        notGreaterThanMin: 'Maximum age must be greater than minimum age',
      },
    },
  ],
};

Common Cross-Field Patterns:

  • Password confirmation matching
  • Date range validation (start < end)
  • Numeric range validation (min < max)
  • Conditional required fields

Password Confirmation Example

{
  key: 'confirmPassword',
  type: 'input',
  validators: [{
    type: 'custom',
    // Pass while either field is empty so the required validator handles that case
    expression: '!fieldValue || !formValue.password || fieldValue === formValue.password',
    kind: 'passwordMismatch',
  }],
  validationMessages: {
    passwordMismatch: 'Passwords do not match'
  }
}

Async Validators (Resource-based)

Async validators use Angular's resource API for database lookups or complex async operations.

Basic Example

import { AsyncCustomValidator } from '@ng-forge/dynamic-forms';
import { inject } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { of } from 'rxjs';
import { UserService } from './user.service';

const checkUsernameAvailable: AsyncCustomValidator = {
  // Extract params from field context
  params: (ctx) => ({ username: ctx.value() }),

  // Create resource with params signal
  factory: (params) => {
    const userService = inject(UserService);
    return rxResource({
      params,
      stream: ({ params }) => {
        if (!params?.username) return of(null);
        return userService.checkAvailability(params.username);
      },
    });
  },

  // Map result to validation error
  onSuccess: (result, ctx) => {
    if (!result) return null;
    return result.available ? null : { kind: 'usernameTaken' };
  },

  // Handle errors gracefully
  onError: (error, ctx) => {
    console.error('Availability check failed:', error);
    return null; // Don't block form on network errors
  },
};

const config = {
  fields: [
    {
      key: 'username',
      type: 'input',
      validators: [{ type: 'async', functionName: 'checkUsernameAvailable' }],
      validationMessages: {
        usernameTaken: 'This username is already taken',
      },
    },
  ],
  customFnConfig: {
    asyncValidators: {
      checkUsernameAvailable,
    },
  },
};

Key Benefits:

  • Automatic loading states via resource API
  • Angular manages resource lifecycle
  • Reactive - refetches when params change
  • Integrates with Signal Forms validation state

Structure

interface AsyncCustomValidator<TValue, TParams, TResult> {
  // Function that receives field context and returns resource params
  readonly params: (ctx: FieldContext<TValue>, config?: Record<string, unknown>) => TParams;

  // Function that creates a ResourceRef from the params signal
  readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;

  // Map successful resource result to validation errors
  readonly onSuccess?: (result: TResult, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;

  // Handle resource errors
  readonly onError?: (error: unknown, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;
}

Declarative HTTP Validators

The simplest way to validate against an HTTP endpoint. No function registration required: configure the request and response mapping inline.

Basic Example

{
  key: 'username',
  type: 'input',
  validators: [
    {
      type: 'http',
      http: {
        url: '/api/users/check-availability',
        method: 'GET',
        queryParams: {
          username: 'fieldValue', // fieldValue refers to the current field
        },
      },
      responseMapping: {
        validWhen: 'response.available', // Must evaluate to boolean true
        errorKind: 'usernameTaken',
      },
    },
  ],
  validationMessages: {
    usernameTaken: 'This username is already taken',
  },
}

Key points:

  • fieldValue is available as an expression in params, queryParams, and body (refers to the current field's value)
  • Use params with :key URL placeholders for path parameters (e.g. url: '/api/users/:id' + params: { id: 'fieldValue' })
  • validWhen is evaluated with { response } in scope and must return boolean true for the field to be valid; non-boolean results log a warning
  • Fail-closed: if the HTTP request errors, the validator returns { kind: errorKind } by default (prevents submission on network failure)

POST with Body Expressions

{
  type: 'http',
  http: {
    url: '/api/validate-email',
    method: 'POST',
    body: {
      email: 'fieldValue',
    },
    evaluateBodyExpressions: true,
  },
  responseMapping: {
    validWhen: 'response.valid',
    errorKind: 'emailInvalid',
  },
}

Error Message Interpolation

Use errorParams to include response data in validation messages:

{
  type: 'http',
  http: {
    url: '/api/users/check-availability',
    queryParams: { username: 'fieldValue' },
  },
  responseMapping: {
    validWhen: 'response.available',
    errorKind: 'usernameTaken',
    errorParams: {
      suggestion: 'response.suggestion', // Maps to {{suggestion}} in the message
    },
  },
}
// Message: 'Username is taken. Try {{suggestion}}'

HttpValidationResponseMapping Interface

interface HttpValidationResponseMapping {
  /** Expression evaluated with { response }. Must evaluate to boolean true to be valid. */
  validWhen: string;

  /** Error kind returned when validWhen is not true; maps to validationMessages. */
  errorKind: string;

  /**
   * Optional parameters for message interpolation.
   * Keys become {{paramName}} placeholders. Values are expressions evaluated with { response }.
   */
  errorParams?: Record<string, string>;
}

When to use declarative vs function-based:

Scenario Use
Standard availability check Declarative (type: 'http' + responseMapping)
Need inject() for Angular services Function-based (HttpCustomValidator)
Conditional request (skip based on field state) Function-based (HttpCustomValidator)
Custom error handling (fail-open on error) Function-based (HttpCustomValidator)

HTTP Validators (Function-Based)

Function-based HTTP validators give you full programmatic control over request construction and response handling.

Basic Example

import { HttpCustomValidator } from '@ng-forge/dynamic-forms';

const checkEmailDomain: HttpCustomValidator = {
  // Build HTTP request from context
  request: (ctx) => {
    const email = ctx.value();
    if (!email?.includes('@')) return undefined; // Skip if invalid

    const domain = email.split('@')[1];
    return {
      url: `/api/validate-domain`,
      method: 'POST',
      body: { domain },
      headers: { 'Content-Type': 'application/json' },
    };
  },

  // NOTE: Inverted logic - onSuccess checks if response indicates INVALID
  // We're validating, not fetching data!
  onSuccess: (response, ctx) => {
    // Assuming API returns { valid: boolean }
    return response.valid ? null : { kind: 'invalidDomain' };
  },

  onError: (error, ctx) => {
    console.error('Domain validation failed:', error);
    return null; // Don't block form on network errors
  },
};

const config = {
  fields: [
    {
      key: 'email',
      type: 'input',
      validators: [{ type: 'http', functionName: 'checkEmailDomain' }],
      validationMessages: {
        invalidDomain: 'This email domain is not allowed',
      },
    },
  ],
  customFnConfig: {
    httpValidators: {
      checkEmailDomain,
    },
  },
};

Key Benefits:

  • Automatic cancellation of in-flight requests when the value changes
  • Prevents race conditions
  • Optimized for HTTP-specific validation

Important: HTTP validators use "inverted logic" - onSuccess should return an error if validation fails, not if the HTTP request succeeds. You're checking validation status, not fetching data.

Inline Alternative (fn)

For code-only projects, skip the customFnConfig.httpValidators registration and attach the validator inline. FunctionHttpValidatorConfig.fn is XOR with functionName: TypeScript rejects both keys at compile time, and the runtime warns + prefers inline if a JSON-loaded config sets them both.

import type { HttpCustomValidator } from '@ng-forge/dynamic-forms';

const checkEmailDomain: HttpCustomValidator = {
  request: (ctx) => {
    const email = ctx.value();
    if (!email?.includes('@')) return undefined;
    return { url: `/api/validate-domain`, method: 'POST', body: { domain: email.split('@')[1] } };
  },
  onSuccess: (response) => (response.valid ? null : { kind: 'invalidDomain' }),
};

const config = {
  fields: [
    {
      key: 'email',
      type: 'input',
      // No customFnConfig.httpValidators entry required — the validator lives on the validator config.
      validators: [{ type: 'http', fn: checkEmailDomain }],
      validationMessages: { invalidDomain: 'This email domain is not allowed' },
    },
  ],
};

Use functionName for configs loaded from JSON/APIs/OpenAPI; use fn for TS-authored configs where you'd rather not maintain a separate registry.

Structure

interface HttpCustomValidator<TValue, TResult> {
  // Build HTTP request from field context
  readonly request: (ctx: FieldContext<TValue>) => HttpResourceRequest | string | undefined;

  // REQUIRED: map successful response to validation error
  readonly onSuccess: (result: TResult, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;

  // Handle HTTP errors
  readonly onError?: (error: unknown, ctx: FieldContext<TValue>) => ValidationError | ValidationError[] | null;
}

interface HttpResourceRequest {
  url: string;
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  body?: unknown;
  headers?: Record<string, string | string[]>;
}

Conditional Custom Validators

Apply validators conditionally using the when property with a ConditionalExpression:

const businessEmailValidator: CustomValidator = (ctx) => {
  const value = ctx.value();
  const domain = value?.split('@')[1];

  const freeEmailDomains = ['gmail.com', 'yahoo.com', 'hotmail.com'];

  if (domain && freeEmailDomains.includes(domain.toLowerCase())) {
    return { kind: 'requireBusinessEmail' };
  }
  return null;
};

const config = {
  fields: [
    {
      key: 'accountType',
      type: 'select',
      value: 'personal',
      options: [
        { value: 'personal', label: 'Personal' },
        { value: 'business', label: 'Business' },
      ],
    },
    {
      key: 'email',
      type: 'input',
      validators: [
        {
          type: 'custom',
          functionName: 'businessEmailValidator',
          // Only apply when account type is "business"
          when: {
            type: 'fieldValue',
            fieldPath: 'accountType',
            operator: 'equals',
            value: 'business',
          },
        },
      ],
      validationMessages: {
        requireBusinessEmail: 'Please use a business email address',
      },
    },
  ],
  customFnConfig: {
    validators: { businessEmailValidator },
  },
};

The validator is only active when the when condition evaluates to true, allowing dynamic validation based on form state. See Conditional Logic for all expression types and operators.

Common Validation Patterns

Email Domain Validation

const emailDomainValidator: CustomValidator = (ctx) => {
  const blockedDomains = ['tempmail.com', 'throwaway.email'];
  const email = ctx.value();
  const domain = email?.split('@')[1];

  if (domain && blockedDomains.includes(domain)) {
    return { kind: 'blockedDomain' };
  }
  return null;
};

Age Validation

const ageValidator: CustomValidator = (ctx) => {
  const birthDate = ctx.value();
  const age = calculateAge(birthDate);

  if (age < 18) {
    return { kind: 'tooYoung' };
  }
  return null;
};

Conditional Required

// Company name required if employed
{
  key: 'companyName',
  type: 'input',
  validators: [{
    type: 'custom',
    expression: "formValue.employmentStatus !== 'employed' || !!fieldValue",
    kind: 'required',
  }],
}

Date Range Validation

{
  key: 'endDate',
  type: 'datepicker',
  validators: [{
    type: 'custom',
    expression: '!formValue.startDate || !fieldValue || formValue.startDate <= fieldValue',
    kind: 'invalidDateRange',
  }],
  validationMessages: {
    invalidDateRange: 'End date must be after start date',
  },
}

Multiple Errors

Validators can return multiple errors:

const passwordStrength: CustomValidator = (ctx) => {
  const value = ctx.value();
  if (typeof value !== 'string' || !value) return null;

  const errors: ValidationError[] = [];

  if (!/[A-Z]/.test(value)) errors.push({ kind: 'missingUppercase' });
  if (!/[0-9]/.test(value)) errors.push({ kind: 'missingNumber' });
  if (value.length < 12) errors.push({ kind: 'tooShort' });

  return errors.length > 0 ? errors : null;
};

Validation Messages

Field-Level Messages

{
  key: 'username',
  validators: [{ type: 'custom', functionName: 'noSpaces' }],
  validationMessages: {
    noSpaces: 'Spaces are not allowed'
  }
}

Form-Level Default Messages

{
  defaultValidationMessages: {
    noSpaces: 'Spaces are not allowed',
    passwordMismatch: 'Passwords must match',
    usernameTaken: 'This username is already taken'
  },
  fields: [/* ... */]
}

Dynamic Messages with i18n

import { inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';

{
  key: 'username',
  validators: [{ type: 'custom', functionName: 'noSpaces' }],
  validationMessages: {
    noSpaces: inject(TranslateService).get('VALIDATION.NO_SPACES')
  }
}

Parameterized Messages

Messages interpolate values from the returned validation error's properties using double curly braces (same syntax as Angular templates). Interpolation reads the error object, not ValidatorConfig.params:

  • Expression-based validators attach evaluated errorParams to the error automatically.
  • Declarative HTTP validators attach responseMapping.errorParams automatically.
  • Function-based validators must copy any values they want interpolated onto the returned error object.
const lessThanField: CustomValidator = (ctx, params) => {
  // ... validation logic ...
  // Copy interpolation values onto the returned error
  return { kind: 'notLessThan', label: params?.['label'] };
};

{
  validators: [
    {
      type: 'custom',
      functionName: 'lessThanField',
      params: { field: 'minAge', label: 'Minimum Age' }
    }
  ],
  validationMessages: {
    // Interpolates the error's label property
    notLessThan: 'Must be less than {{label}}'
  }
}

The validation message renders as "Must be less than Minimum Age" because the returned error carries label: 'Minimum Age'.

Type Safety

All validator types are fully typed. While validators can optionally use generic type parameters for stricter typing, the simple form without generics works well for most cases:

// Simple form - works for most cases
const noSpaces: CustomValidator = (ctx) => {
  const value = ctx.value();
  if (typeof value === 'string' && value.includes(' ')) {
    return { kind: 'noSpaces' };
  }
  return null;
};

// With type parameter - for stricter typing (advanced)
const strictNoSpaces: CustomValidator<string> = (ctx) => {
  const value = ctx.value(); // Type: string
  // TypeScript knows value is always string
  return value.includes(' ') ? { kind: 'noSpaces' } : null;
};

// Async validators with type parameters (advanced)
const checkUsername: AsyncCustomValidator<string, { username: string }, { available: boolean }> = {
  params: (ctx) => ({ username: ctx.value() }),
  factory: (params) => {
    /* ... */
  },
  onSuccess: (result, ctx) => {
    result.available; // Type: boolean
    return result.available ? null : { kind: 'usernameTaken' };
  },
};

// HTTP validators with type parameters (advanced)
const checkDomain: HttpCustomValidator<string, { valid: boolean }> = {
  request: (ctx) => ({
    /* ... */
  }),
  onSuccess: (response, ctx) => {
    response.valid; // Type: boolean
    return response.valid ? null : { kind: 'invalidDomain' };
  },
};

Note: When registering validators in customFnConfig.validators, use the simple form without type parameters to avoid TypeScript compatibility issues.

Best Practices

  1. Separation of Concerns: Return only error kind, configure messages separately
  2. i18n Support: Use Observable/Signal for validation messages
  3. Graceful Degradation: Handle async/HTTP errors without blocking the form
  4. Cross-Field Validation: Use expression-based validators that read formValue, or a form-level schema
  5. Type Safety: Use TypeScript generics for type-safe validation
  6. Message Priority: Use field-level messages for customization, form-level for common errors
  7. Conditional Validation: Use when property with ConditionalExpression for dynamic validators
  8. Inverted Logic: HTTP validators check validity, not data fetching success

Inline functions vs registered names

Every validator/condition/derivation surface that accepts a functionName (or asyncFunctionName) also accepts an inline fn (or asyncFn):

Surface Registered (JSON-safe) Inline (code-only)
Sync custom validator { type: 'custom', functionName } { type: 'custom', fn }
Async validator { type: 'async', functionName } { type: 'async', fn }
Function-based HTTP validator { type: 'http', functionName } { type: 'http', fn }
Custom condition { type: 'custom', functionName } { type: 'custom', fn }
Async condition { type: 'async', asyncFunctionName } { type: 'async', asyncFn }
Function derivation { type: 'derivation', functionName } { type: 'derivation', fn }
Async function derivation { type: 'derivation', source: 'asyncFunction', asyncFunctionName } { type: 'derivation', source: 'asyncFunction', asyncFn }

Rules of thumb:

  • Use functionName / asyncFunctionName for configs that travel over the wire: API responses, OpenAPI schemas, JSON files, database rows. The MCP server only emits the registered form.
  • Use fn / asyncFn for code-only configs in TypeScript when you don't need JSON serializability. The function reference is captured directly, with no registry indirection.
  • The two are mutually exclusive. TypeScript rejects setting both at compile time; if a JSON-loaded config sneaks both keys through at runtime, a warning is logged and the inline form wins.