Angular's signal forms include native schema APIs for form-level validation. This approach requires no additional dependencies and works directly with Dynamic Forms.

For maximum simplicity, pass Angular's schema callback directly without any wrapper:

import { FormConfig } from '@ng-forge/dynamic-forms';
import { validateTree } from '@angular/forms/signals';

const config = {
  // Raw callback - no wrapper needed!
  schema: (path) => {
    validateTree(path, (ctx) => {
      const { password, confirmPassword } = ctx.value();
      if (password !== confirmPassword) {
        return [{ kind: 'passwordMismatch', fieldTree: ctx.fieldTreeOf(path).confirmPassword }];
      }
      return null;
    });
  },
  fields: [
    { key: 'password', type: 'input', label: 'Password', required: true, props: { type: 'password' } },
    {
      key: 'confirmPassword',
      type: 'input',
      label: 'Confirm Password',
      required: true,
      validationMessages: { passwordMismatch: 'Passwords must match' },
      props: { type: 'password' },
    },
    { key: 'submit', type: 'submit', label: 'Register' },
  ],
} as const satisfies FormConfig;

This pattern gives you full access to Angular's validation APIs including validateTree, validate, and required.

Combining Field and Schema Validation

Field-level validators (like required, minLength) and the schema callback both run reactively, and their errors are combined on the affected fields.

The schema() Wrapper

Angular also exports a schema() function that wraps a callback into a Schema<T> object. Dynamic Forms does not accept this wrapper: the schema property of FormConfig takes either a raw callback (path) => void or a standardSchema() marker. A Schema object created with schema() is ignored.

To reuse validation logic across forms, type and export the callback itself:

import { validate, SchemaPathTree } from '@angular/forms/signals';

interface PasswordForm {
  password: string;
  confirmPassword: string;
}

const passwordSchema = (p: SchemaPathTree<PasswordForm>) => {
  validate(p.confirmPassword, ({ value, valueOf }) => (value() === valueOf(p.password) ? null : { kind: 'passwordMismatch' }));
};

The returned passwordMismatch kind maps to display text via the field's validationMessages (or form-level defaultValidationMessages).

Using with Dynamic Forms

Pass the raw callback to your form configuration. The callback receives the schema path tree, and you bind validators to paths inside it:

import { FormConfig } from '@ng-forge/dynamic-forms';
import { validate } from '@angular/forms/signals';

const config = {
  schema: (path) => {
    validate(path.confirmPassword, ({ value, valueOf }) => (value() === valueOf(path.password) ? null : { kind: 'passwordMismatch' }));
  },
  fields: [
    {
      key: 'password',
      type: 'input',
      label: 'Password',
      required: true,
      minLength: 8,
      props: { type: 'password' },
    },
    {
      key: 'confirmPassword',
      type: 'input',
      label: 'Confirm Password',
      required: true,
      validationMessages: {
        passwordMismatch: 'Passwords must match',
      },
      props: { type: 'password' },
    },
    {
      key: 'submit',
      type: 'submit',
      label: 'Register',
    },
  ],
} as const satisfies FormConfig;

Schema API Reference

The schema callback

Dynamic Forms invokes your callback with the schema path tree for the form value. Navigate to a field's path with property access (path.email, path.address.city) and bind validation logic to it:

schema: (path) => {
  // path.fieldKey is a path object, not a value
  // bind logic with validate(), validateTree(), required(), ...
};

validate(path, logic)

Binds a validator to a path and returns void. The logic function receives a FieldContext and returns ValidationError | ValidationError[] | null. Use value() for the bound field's value and valueOf(otherPath) for other fields:

validate(path.endDate, ({ value, valueOf }) => {
  const start = valueOf(path.startDate);
  const end = value();
  if (!start || !end) return null;
  return end >= start ? null : { kind: 'invalidRange' };
});

validateTree(path, logic)

Like validate, but the returned errors can target specific fields in the subtree via fieldTree (see the password example above). Useful when one rule produces errors on several fields.

required(path, config?)

Marks a field as required. Pass when for conditional requiredness:

required(path.endDate, {
  when: ({ valueOf }) => valueOf(path.hasEndDate) === true,
});

Combining Validators

Call as many validation functions as you need inside one callback:

schema: (path) => {
  validate(path.confirmPassword, ({ value, valueOf }) => (value() === valueOf(path.password) ? null : { kind: 'passwordMismatch' }));
  validate(path.endDate, ({ value, valueOf }) => (!value() || value() > valueOf(path.startDate) ? null : { kind: 'invalidRange' }));
};

Examples

Date Range Validation

const config = {
  schema: (path) => {
    validate(path.endDate, ({ value, valueOf }) => {
      const start = valueOf(path.startDate);
      const end = value();
      if (!start || !end) return null;
      return new Date(end) >= new Date(start) ? null : { kind: 'invalidRange' };
    });
  },
  fields: [
    {
      key: 'startDate',
      type: 'datepicker',
      label: 'Start Date',
      required: true,
    },
    {
      key: 'endDate',
      type: 'datepicker',
      label: 'End Date',
      required: true,
      validationMessages: {
        invalidRange: 'End date must be after start date',
      },
    },
  ],
} as const satisfies FormConfig;

Conditional Required Fields

const config = {
  schema: (path) => {
    required(path.email, { when: ({ valueOf }) => valueOf(path.preferredContact) === 'email' });
    required(path.phone, { when: ({ valueOf }) => valueOf(path.preferredContact) === 'phone' });
  },
  fields: [
    /* preferredContact, email, phone */
  ],
} as const satisfies FormConfig;

Complex Business Rules

const config = {
  schema: (path) => {
    // Percentage discount cannot exceed 100%
    validate(path.discount, ({ value, valueOf }) =>
      valueOf(path.discountType) === 'percentage' && value() > 100 ? { kind: 'maxPercentage' } : null,
    );

    // Fixed discount cannot exceed total
    validate(path.discount, ({ value, valueOf }) =>
      valueOf(path.discountType) === 'fixed' && value() > valueOf(path.total) ? { kind: 'exceedsTotal' } : null,
    );
  },
  fields: [
    /* quantity, discount, discountType, total */
  ],
} as const satisfies FormConfig;

Best Practices

Keep schemas focused:

// Good - single responsibility
schema: (path) => {
  validate(path.confirmPassword, ({ value, valueOf }) => (value() === valueOf(path.password) ? null : { kind: 'passwordMismatch' }));
};

// Avoid - too many concerns in one callback
schema: (path) => {
  // 10+ validations...
};

Use field validators for simple cases:

// Use field validators when possible
{
  key: 'email',
  required: true,
  email: true,
}

// Use schema for cross-field only
schema: (path) => {
  validate(path.alternateEmail, ({ value, valueOf }) =>
    value() !== valueOf(path.email) ? null : { kind: 'sameAsEmail' },
  );
};

When to Use Standard Schema Instead

Consider Standard Schema (Zod) if you:

  • Already have Zod schemas from an API or shared library
  • Need the same validation in Node.js backend
  • Want automatic TypeScript type inference from schemas
  • Are using OpenAPI-generated schemas

Next Steps