Angular 21 signal forms: How to write custom schema validator functions for union types

Summary

The problem revolves around creating custom schema validator functions for union types in Angular 21 signal forms. The goal is to validate a Dashboard object that contains an array of Widget objects, where Widget is a union type of WidgetA, WidgetB, and WidgetC. The challenge lies in applying the correct validation function to each Widget object based on its type.

Root Cause

The root cause of the issue is the lack of a built-in applySwitch function in Angular’s schema validation API, similar to applyEach. This makes it difficult to apply different validation functions to each item in an array based on its type.

Why This Happens in Real Systems

This issue occurs in real systems when dealing with complex data models that involve union types and nested objects. The need to validate such data structures arises in various applications, including:

  • Form validation: Validating user input data in forms
  • Data import/export: Validating data being imported or exported from/to external systems
  • API request/response validation: Validating data sent or received through APIs

Real-World Impact

The impact of not being able to properly validate union types can lead to:

  • Invalid data: Allowing invalid data to enter the system, potentially causing errors or security vulnerabilities
  • Poor user experience: Failing to provide accurate feedback to users about invalid input data
  • Increased maintenance costs: Having to manually handle validation errors or fix issues caused by invalid data

Example or Code

interface Dashboard {
  widgets: Widget[];
}

interface WidgetA extends WidgetBase {}
interface WidgetB extends WidgetBase {}
interface WidgetC extends WidgetBase {}

type Widget = WidgetA | WidgetB | WidgetC;

export function validateDashboard(path: SchemaPathTree): void {
  min(path.widgets, 1);
  applyEach(path.widgets, validateWidget);
}

export function validateWidget(path: SchemaPathTree): void {
  switch (path.value.constructor) {
    case WidgetA:
      validateWidgetA(path);
      break;
    case WidgetB:
      validateWidgetB(path);
      break;
    case WidgetC:
      validateWidgetC(path);
      break;
    default:
      throw new Error('Unknown widget type');
  }
}

function validateWidgetBase(path: SchemaPathTree): void {
  required(path.name);
}

export function validateWidgetA(path: SchemaPathTree): void {
  validateWidgetBase(path);
  // add other validations here
}

export function validateWidgetB(path: SchemaPathTree): void {
  //...
}

export function validateWidgetC(path: SchemaPathTree): void {
  //...
}

How Senior Engineers Fix It

Senior engineers fix this issue by:

  • Using a switch statement to determine the type of each Widget object and apply the corresponding validation function
  • Creating a registry of validation functions for each Widget type
  • Using a decorator or metadata to associate validation functions with each Widget type

Why Juniors Miss It

Juniors may miss this issue due to:

  • Lack of experience with union types and nested objects
  • Insufficient understanding of the schema validation API
  • Failure to consider the real-world impact of invalid data on the system
  • Not following best practices for naming and organizing validation functions, such as using TypeName + Schema as recommended in the documentation. Key takeaways for juniors include:
  • Understanding the importance of proper validation for union types and nested objects
  • Familiarizing themselves with the schema validation API and its limitations
  • Following best practices for naming and organizing validation functions
  • Considering the real-world impact of invalid data on the system.

Leave a Comment