DEV Community

Dawid Szemro
Dawid Szemro

Posted on

Mastering Form Error Handling in Angular: Mapping Errors to User-Friendly Messages

Introduction

Form validation is an essential part of most web applications, but mapping form errors to clear, user-friendly error messages is often overlooked. In many Angular apps, error-handling logic can become messy and inconsistent, which can make it difficult to maintain or scale as forms grow in complexity.

A well-thought-out approach to mapping form errors to error messages brings several benefits. It makes your component templates more readable, ensuring they stay concise and focused on displaying data rather than handling logic. It also enhances maintainability, as a centralized error-mapping structure is easier to update when requirements change. Lastly, reusability improves, as the same error-handling logic can be easily applied across different forms and components.

In this post, we’ll explore how to implement a clear, scalable strategy for mapping form errors to user-friendly messages in Angular, transforming an often-neglected aspect of form handling into an asset for your application’s codebase.

This blog post is based on concepts implemented in ngx-error-msg.

The Problem with a Trivial Implementation: Why Using "Ifs" in Component Templates Falls Short

One common but problematic way to handle form error messages in Angular is by directly embedding @if conditions within the component template. At first glance, this approach might seem straightforward—just check for each specific error type and display the corresponding message. However, as forms grow in complexity, this pattern quickly becomes unwieldy and introduces several major issues.

  1. Code Duplication - using @if for each error type often leads to repetitive logic. For instance, common error messages like "This field is required" or "Invalid format" may be duplicated across multiple fields. As the form evolves with new validations, these repetitive checks grow, bloating the template.
  2. Complex "Iffology" that Overcomplicates Templates - a template peppered with @if conditions creates what’s known as “iffology”—a tangled mess of conditionals that obscure the structure. Instead of a clean, readable layout, developers must sift through nested logic, making templates harder to understand, debug, and maintain.
  3. Maintainability Challenges - the combination of duplicated code and complex @if logic makes maintaining such forms a nightmare. As the codebase grows, the likelihood of errors, oversights, or inconsistencies increases.

While embedding 'ifs in templates' might seem quick and easy, it leads to bloated templates, tangled logic, and maintenance issues. A better solution is a centralized error-mapping strategy that simplifies templates, reduces duplication, and makes your codebase easier to maintain.

Goals for our Error Mapping Solution

To address the limitations of a trivial implementation, our error-mapping solution should achieve the following goals:

  1. Decouple Error Mapping from Components - extracting error mapping keeps component templates focused on structure. This improves readability and simplifies maintenance across forms.
  2. Simplify Templates with Declarative Syntax - the solution should allow templates to display error messages in a straightforward, declarative way. This reduces "iffology" and ensures error handling doesn’t clutter the HTML, keeping templates easy to understand.
  3. Eliminate Redundancy - define common messages like “This field is required” once at a root level, while allowing field-specific overrides where needed. This ensures consistency and reduces repetitive code.
  4. Support Internationalization (i18n) Libraries - ensure compatibility with libraries like Transloco or ngx-translate for runtime translation. This allows seamless localization, so users receive error messages in their preferred language.

Building the Solution: Core Elements Explained

To tackle the challenges and meet the goals, we designed a robust error-mapping solution using three key components: a mapper service, an injected global mapping object, and a structural directive. This system keeps mapping logic out of components, simplifies template usage, supports field-specific and generic error handling, and integrates seamlessly with i18n tools like Transloco or ngx-translate.

Mapper Service

The mapper service centralizes the error-to-message mapping logic. It ensures components remain clean and focused by abstracting this functionality into a reusable service. With support for both global and field-specific mappings, it provides consistency across the app, simplifies testing, and makes updates easier as requirements evolve.

Injected Global Mapping Object

This global configuration object defines default error messages shared across the application. It minimizes duplication, ensures consistency, and serves as the foundation for reusable error-mapping logic.

Structural Directive

The structural directive allows declarative error handling directly in templates, reducing clutter and avoiding repetitive conditional logic. By encapsulating error-checking into a directive, templates become cleaner, easier to maintain, and aligned with Angular’s declarative approach.

Step-by-Step Implementation Guide

Step 1. Base Types and Global Message Mapping Token

We introduce an InjectionToken called ERROR_MSG_MAPPINGS and two associated types: ErrorMessageMapper and ErrorMessageMappings. These definitions lay the groundwork for a global error-message mapping system. The token facilitates dependency injection, while the types ensure type safety and consistency in mapping errors to messages. This initial step is crucial for creating a robust and extensible structure to support customizations and future enhancements.

export const ERROR_MSG_MAPPINGS = new InjectionToken<ErrorMessageMappings>(
  "ERROR_MSG_MAPPINGS"
);

export type ErrorMessageMapper = (error: any) => string;
export type ErrorMessageMappings = Record<string, ErrorMessageMapper>;
Enter fullscreen mode Exit fullscreen mode

Example usage of the injection token:

{
  provide: ERROR_MSG_MAPPINGS,
  useValue: {
    required: () => `This field is required`,
    minlength: (error: any) => `Minimum length is ${error.requiredLength}.`,
  },
}
Enter fullscreen mode Exit fullscreen mode

Step 2. Output Shape

The MappedMessage interface (error and message) provides a clear structure for mapping form errors to user-friendly messages. This shape is designed to separate technical error codes from display logic.

export interface MappedMessage {
  error: string;
  message: string;
}
Enter fullscreen mode Exit fullscreen mode

Step 3. Mapper service

The next step is the implementation of the ErrorMsgService, which centralizes the logic for converting validation errors into error messages. This service prioritizes error messages based on their order in the ErrorMessageMappings, ensuring a predictable and consistent behavior. Additionally, by returning the messages as Observables, the service aligns with Angular's reactive paradigm and supports seamless integration with internationalization (I18N) libraries, allowing dynamic translations in real-time (it will be done in the next steps).

@Injectable({ providedIn: 'root' })
export class ErrorMsgService {
  toErrorMessages(
    errors: ValidationErrors | null,
    errorMsgMappings: ErrorMessageMappings,
  ): Observable<MappedMessage[]> {
    if (!errors) {
      return of([]);
    }

    const messageObservables = this.getMappedErrorMessageObservables(errors, errorMsgMappings);

    if (messageObservables.length === 0) {
      return of([]);
    }

    return combineLatest(messageObservables);
  }

  private getMappedErrorMessageObservables(
    errors: ValidationErrors,
    errorMsgMappings: ErrorMessageMappings,
  ): Observable<MappedMessage>[] {
    // It prioritizes error messages based on their order in the errorMsgMappings.
    const errorsMapEntries = Object.entries(errorMsgMappings).filter(([key]) => errors[key]);

    return errorsMapEntries.map(([key, errorMapping]) =>
      this.toErrorMessageObservable(errorMapping, errors[key]).pipe(
        map(message => ({ error: key, message })),
      ),
    );
  }

  private toErrorMessageObservable(mapping: ErrorMessageMapper, error: any): Observable<string> {
    const resolvedMessage = mapping(error);

    return of(resolvedMessage);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4. Better customization with context data

To support more flexible error messages, we extend the ErrorMessageMapper type to accept a ctx parameter. This parameter, represented by the type ErrorMsgContext (Record<string, any>), provides additional context to mappings, allowing them to dynamically adjust messages based on field-level details or other runtime data.

export type ErrorMsgContext = Record<string, any>;
export type ErrorMessageMapper = (error: any, ctx: ErrorMsgContext) => string;
Enter fullscreen mode Exit fullscreen mode

To integrate the context functionality into the ErrorMsgService, we add a ctx parameter to its methods. This parameter ensures that all contextual data is available where needed, allowing error mappings to dynamically adapt to the specific requirements of each field or validation scenario.

toErrorMessages(
  errors: ValidationErrors | null,
  errorMsgMappings: ErrorMessageMappings,
  ctx: ErrorMsgContext = {}
): Observable<MappedMessage[]> {
  // ...
  const messageObservables = this.getMappedErrorMessageObservables(
    errors,
    errorMsgMappings,
    ctx
  );
  // ...
}

private getMappedErrorMessageObservables(
  errors: ValidationErrors,
  errorMsgMappings: ErrorMessageMappings,
  ctx: ErrorMsgContext
): Observable<MappedMessage>[] {
  // ...
  return errorsMapEntries.map(([key, errorMapping]) =>
    this.toErrorMessageObservable(errorMapping, errors[key], ctx).pipe(
      map((message) => ({ error: key, message }))
    )
  );
}

private toErrorMessageObservable(
  mapping: ErrorMessageMapper,
  error: any,
  ctx: ErrorMsgContext
): Observable<string> {
  const resolvedMessage = mapping(error, ctx);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

By incorporating the ctx parameter, error mappings become more dynamic. For example, a mapping for the required error can use the ctx.field property to generate personalized messages like "_Password is required".

Step 5. Add i18n support

To integrate internationalization (i18n) support, we update the ErrorMessageMapperFn type. It now allows the return type to be either an Observable<string> or a plain string. This flexibility accommodates static error messages and dynamic ones that rely on asynchronous translation services, ensuring smooth integration with i18n libraries like Transloco or ngx-translate.

export type ErrorMessageMapper = (
  error: any,
  ctx: ErrorMsgContext
) => Observable<string> | string;
Enter fullscreen mode Exit fullscreen mode

With this update, we can adapt the toErrorMessageObservable method in the service to handle both synchronous and asynchronous return values. If the mapping returns an Observable, it is used directly; otherwise, the string result is wrapped in an Observable using of.

private toErrorMessageObservable(
  mapping: ErrorMessageMapper,
  error: any,
  ctx: ErrorMsgContext
): Observable<string> {
  const resolvedMessage = mapping(error, ctx);

  return typeof resolvedMessage === "string"
    ? of(resolvedMessage)
    : resolvedMessage;
}
Enter fullscreen mode Exit fullscreen mode

Step 6. Add directive

To achieve the goal of declarative usage for error messages, we introduce a structural directive, appErrorMsg. This directive simplifies the integration of dynamically translated error messages by exposing them as an $implicit context. It ensures clean templates while leveraging Angular's ngTemplateContextGuard for better type safety.

Through its inputs, the directive binds to errors, error message mappings, and contextual data. It interacts with an ErrorMsgDirService (which will be implemented in the next step), encapsulating state management and ensuring that the directive remains focused on rendering logic. This separation adheres to the Single Responsibility Principle.

interface Context {
  $implicit: MappedMessage[] | null;
}

@Directive({
  selector: "[appErrorMsg]",
  standalone: true,
  providers: [ErrorMsgDirService],
})
export class ErrorMsgDirective implements OnInit, OnDestroy {
  private readonly service = inject(ErrorMsgDirService);
  private readonly templateRef = inject(TemplateRef);
  private readonly viewContainerRef = inject(ViewContainerRef);

  @Input("appErrorMsg") set errors(errors: ValidationErrors | null) {
    this.service.setErrors(errors);
  }

  @Input("appErrorMsgMappings") set errorsMapping(value: ErrorMessageMappings) {
    this.service.setErrorMsgMappings(value);
  }

  @Input("appErrorMsgCtx") set ctx(value: ErrorMsgContext) {
    this.service.setContext(value);
  }

  private subscription: Subscription | null = null;

  static ngTemplateContextGuard(
    _: ErrorMsgDirective,
    ctx: any
  ): ctx is Context {
    return true;
  }

  ngOnInit() {
    this.subscription = this.service.errorMessages$.subscribe(
      (messages) => {
        this.viewContainerRef.clear();
        this.viewContainerRef.createEmbeddedView<Context>(this.templateRef, {
          $implicit: messages,
        });
      }
    );
  }

  ngOnDestroy() {
    this.subscription?.unsubscribe();
    this.viewContainerRef.clear();
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 7. Add service with state

The ErrorMsgDirService is a dedicated service that manages the state and logic required for dynamically mapping and rendering error messages. It acts as the backbone for the appErrorMsg directive, ensuring seamless integration of multiple sources of error mappings while maintaining a reactive flow.

The joinMappings method combines local and injected mappings. The implementation overrides prioritize mappings bound to the service over the injected ones.

The final errorMessages$ observable encapsulates the complete transformation pipeline, turning raw errors into user-friendly messages using the ErrorMsgService.

@Injectable()
export class ErrorMsgDirService {
  private readonly mapper = inject(ErrorMsgService);
  private readonly injectedMappings = inject(ERROR_MSG_MAPPINGS, {
    optional: true,
  });

  private readonly errorMsgMappings$ =
    new BehaviorSubject<ErrorMessageMappings>({});
  private readonly errors$ = new BehaviorSubject<ValidationErrors | null>(null);
  private readonly ctx$ = new BehaviorSubject<ErrorMsgContext>({});

  private readonly joinedMappings$ = this.errorMsgMappings$.pipe(
    map((mappings) => this.joinMappings(this.injectedMappings ?? {}, mappings))
  );

  readonly errorMessages$ = combineLatest({
    errors: this.errors$,
    errorsMap: this.joinedMappings$,
    ctx: this.ctx$,
  }).pipe(
    switchMap(
      ({ errors, errorsMap, ctx }) =>
        this.mapper.toErrorMessages(errors, errorsMap, ctx) ?? of(null)
    )
  );

  setErrors(errors: ValidationErrors | null) {
    this.errors$.next(errors);
  }

  setErrorMsgMappings(mappings: ErrorMessageMappings) {
    this.errorMsgMappings$.next(mappings);
  }

  setContext(ctx: ErrorMsgContext) {
    this.ctx$.next(ctx);
  }

  private joinMappings(
    injectedMappings: ErrorMessageMappings,
    boundMappings: ErrorMessageMappings
  ): ErrorMessageMappings {
    const result: ErrorMessageMappings = {};

    for (const [key, value] of Object.entries(boundMappings)) {
      result[key] = value;
    }

    for (const [key, value] of Object.entries(injectedMappings)) {
      if (!result[key]) {
        result[key] = value;
      }
    }

    return result;
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage

Once implemented, the directive is ready to use. Import it into your component or NgModule, then apply it in your templates by passing the errors object and optionally providing a ctx value or field-specific mappings. Field-level mappings are required if no root-level mappings are defined.

<div
  *appErrorMsg="
    myControl.errors;
    ctx: { field: 'My field' };
    mappings: myControlMappings;
    let mappedErrors
  "
>
  @for(mappedError of mappedErrors; track mappedError.error) {
  {{ mappedError.message }}
  }
</div>
Enter fullscreen mode Exit fullscreen mode

To reduce duplication in mapping common errors, consider defining a root-level mapping.

{
  provide: ERROR_MSG_MAPPINGS,
  useValue: {
    required: (_: any, ctx: ErrorMsgContext) =>
      `${ctx["field"] ?? "This field"} is required.`,
    minlength: (error: any) => `Minimum length is ${error.requiredLength}.`,
  },
};
Enter fullscreen mode Exit fullscreen mode

Summary

This blog post presented a scalable and maintainable solution for mapping form errors to user-friendly messages in Angular. By centralizing error mapping logic and adopting a declarative programming style, the approach tackles common challenges like template clutter, duplicated logic, and poor adaptability.

Key Benefits

  • Cleaner Templates: Error mapping is centralized in services and directives, leaving templates focused solely on rendering messages.
  • Declarative Design: Simplifies error handling by removing manual mapping logic from templates, reducing complexity.
  • Extensibility: Supports dynamic contexts and multilingual libraries (e.g., Transloco, ngx-translate).
  • Reactivity: Ensures real-time updates when errors, mappings, or contexts change.

Potential Pitfalls

While this solution offers many advantages, it’s not without challenges:

  • Overhead for Small Apps: The setup might feel like overengineering for simple use cases compared to straightforward @if checks.
  • Steeper Learning Curve: Developers new to this pattern may need time to implement it.

Ideas for Extending the Mechanism

The system’s flexibility allows for various extensions, such as:

  • Custom prioritization rules (e.g. by field importance).
  • Warnings for unhandled errors during development or runtime.
  • A concatenated string of all error messages for simpler display scenarios.

Conclusion

This approach balances maintainability, scalability, and adaptability, making it ideal for complex Angular applications. While the setup may require initial effort, the long-term benefits—cleaner code, reusable logic, and robust i18n support—outweigh the investment. For quick adoption, consider leveraging libraries like ngx-error-msg, which implements this concept as a ready-to-use package.

Top comments (0)