DEV Community

Cover image for Simplifying Error Handling in Angular Forms with a Custom Directive
Cezar Pleșcan
Cezar Pleșcan

Posted on • Edited on

Simplifying Error Handling in Angular Forms with a Custom Directive

Introduction

In my previous article, I walked you through building an Angular user profile editor. While the initial implementation got the job done, there's always room for improvement. Today, I'm taking the next step in optimizing our form by refactoring the way the validation error messages are displayed.

A quick note: If you're new to this series, you might find it helpful to catch up on the previous article. It'll provide you with the context and foundation you need to fully grasp the concepts I'll cover here.

If you're new to the concept, refactoring is the process of restructuring existing code without changing its external behavior. So, why bother with refactoring?

The benefits are significant:

  • Enhanced readability: Refactored code is often more concise and easier to understand, making it simpler for us to maintain and extend the codebase in the future.
  • Improved maintainability: By identifying and eliminating duplicate code, clarifying responsibilities, and simplifying complex logic, refactoring makes our code less prone to bugs and easier to update.
  • Increased reusability: Refactoring often involves identifying common patterns and extracting them into reusable components or functions. This saves us time and effort in the long run.
  • Better performance: In some cases, refactoring can lead to performance optimizations by eliminating redundancies or improving algorithmic efficiency.

In this article, I'll show you exactly how to improve the way error messages are displayed in our form. I'll use step-by-step explanations to make these changes easy to understand. By the end, you'll see how these small tweaks can make the code much better!

Identifying the current issues

I'll start by examining user-profile.component.html. What catches my attention is the amount of logic within the first <mat-error> element associated with the Name input field

I prefer a more declarative approach. The current code within the element is procedural, with an if statement and repeated calls to form.get('name'). Additionally, the error messages are explicitly associated with the error codes, making the template less flexible.

I'd like to move this logic outside the template so that the view's main responsibility is simply rendering the content. Any additional logic should be handled elsewhere. This aligns with the software design principle of Separation of concerns, allowing the template to focus on presentation and leaving error handling to other components.

My approach is to create an abstraction for the code inside <mat-error>, similar to a function, with an input (the form control, form.get('name')) and an output (the error message). The question is: how can I generate the appropriate content inside the element based on the form control's state? In Angular, we have two main options: pipes and directives. Let's explore each implementation, discuss their tradeoffs, and then choose the best option for our context.

Implement a pipe for displaying form field errors

Usage of the pipe

I'll start by sketching out how the view could use this pipe, and then I'll work on its implementation. I'll name this pipe validationErrorMessage, and it will take the form control, form.get('name'), as input.

<mat-error>
  {{ form.get('name') | validationErrorMessage }}
</mat-error>
Enter fullscreen mode Exit fullscreen mode

This code passes the form.get('name') form control object into the validationErrorMessage pipe. The pipe will process this input and return the appropriate error message, which will then be displayed within the <mat-error> element. I also need to make sure the pipe handles cases where form.get('name') is invalid or has no errors, so it doesn't display anything in those situations.

Implementing the pipe

This pipe should return the validation error messages of the specified form control. I expect the pipe to update the message when the error changes or clear it when the field becomes valid.

But when is the pipe (re)evaluated? It depends whether the pipe is pure or not:

  • if the pipe is pure, it is evaluated at every change detection cycle but only when its input changes, which implies a performance optimization;
  • if the pipe is impure, it is evaluated at every change detection cycle, regardless of its input changes. Our pipe should run only when the field errors change, so I'll create a pure pipe.

In the code example above, I noticed the pipe input is the form control itself. Since the form control is a single reference and doesn't change along with the errors, I'll pass the form control errors object to the pipe instead:

<mat-error>
  {{ form.get('name')?.errors | validationErrorMessage }}
</mat-error>
Enter fullscreen mode Exit fullscreen mode

Note: The optional chaining operator ?. is necessary because the Angular compiler would complain that Object is possibly 'null'. This is because there's no guarantee that the get method will always return a form control object due to its input parameter, which could theoretically be any string.

Here is the implementation of this pipe:

and the usage in the template:




The errors parameter in the pipe's transform method is an object generated by the Angular forms, where the keys represent the error codes. The values could be true (generated internally by Angular validators) or the message itself (in our app, generated explicitly by the server). The Object.entries method converts the errors object into an array of key-value pairs, allowing us to easily iterate over each error type and its corresponding value.

If the value is a string, the pipe will return it. Otherwise, it will read the error message from the ERROR_MESSAGES dictionary. Moreover, if there are multiple error messages (even though in our application this won't be the case), they will be concatenated.

The ERROR_MESSAGES dictionary is meant to contain messages for certain error codes and isn't associated with any form fields. These codes are usually generated by validators, like Validators.required or Validators.email (you can have a look at the code where the form object is defined in the component class). Such validators don't generate the error messages themselves; they only indicate the presence of validation errors.

Important Note: This implementation, while effective for displaying error messages in out specific scenario, is not a general solution applicable to all projects. There are scenarios that my solution doesn't cover. I want to highlight that displaying form field errors is a very complex topic if we try to cover every possible scenario. My intention is to improve code quality by presenting this concrete example and applying software design principles when we have to deal with repeated code or some logic that should be moved from its original location.

Reusing the pipe

Now that the pipe is working for the name field, I want to explore how can it be reused for other fields. Since the pipe is designed to be generic, I can easily apply it to the email field as well:

To accomodate validation errors for emails, I've updated the ERROR_MESSAGES dictionary to include the error for invalid emails:
While this approach works well for simple scenarios, as the form becomes more complex with additional fields and validation rules, we might need a more structured way to manage the error messages. I'll delve into potential solutions for organizing and customizing error messages in the following sections.

Extending and customizing error messages

There could be situations where we want to display custom error messages for specific fields, instead of the generic ones, or when we have additional form fields. For example, I want to display the message "Please fill in the name", instead of the generic "This field is required" when the name field is empty, and similarly for the email field, "Please fill in the email".

The ERROR_MESSAGES dictionary contains pairs of error codes and messages but isn't aware of the fields the errors apply to. While I could theoretically change the dictionary to include the field names, this would tightly couple the pipe to my form fields. If I had another component with different form fields, the dictionary would have to be updated with specific messages for those fields, creating chaos in the code: whenever new forms will be created, the dictionary would need to be updated too, and this reduces the code maintainability, extensibility and scalability.

I definitely want to maintain the pipe's independence from its consumers. How can I do this? I'll pass an additional argument to the pipe with custom error messages for a specific field. And where can I define these messages? To answer this, I first need to address: to which elements do the messages relate? They relate to the form fields. And where are the form fields defined? They are defined in the component. So, I'll create the error messages where the form fields are defined.

Here is the updated template where I pass an argument to the validationError pipe:

I've also added a second parameter to the pipe method for the custom error messages:
The custom error messages used in the template are defined in the component class:
In addition, I've defined two new types that are used in the pipe and in the component:
This approach allows us to easily extend and customize error messages for different fields without cluttering the pipe code or sacrificing its reusability. As our application grows, we can add new fields and their corresponding error messages to the errorMessages object in the component, keeping the validation logic organized and maintainable.

Checking out the code

The code I've described here can be found in the 12.validation-error-pipe branch of the repository https://github.com/cezar-plescan/user-profile-editor/tree/12.validation-error-pipe.

Feel free to explore the repository and experiment with the code yourself!

Implement a directive for displaying form field errors

Usage of the directive

Similar to the pipe implementation, I'll start by defining how I can use a directive to generate error messages within <mat-error> elements. This directive, which I'll name appValidationError, will be applied directly to the <mat-error> element:

<mat-error [appValidationError]="form.get('name')?.errors" [customMessages]="errorMessages?.['name']"></mat-error>
Enter fullscreen mode Exit fullscreen mode

The directive will have 2 inputs:

  • appValidationError: the errors object obtained from the form control.
  • customMessages (optional): custom messages specific to that field.

The directive will then be responsible for dynamically generating and displaying the appropriate error message based on the input values. It will also handle cases where there are no errors or the form control is invalid.

Let's now dive into the implementation details of this appFieldErrors directive.

Implementation of the directive

When using a pipe, we explicitly control where its output is rendered using the interpolation syntax. However, with a directive, we adopt a more declarative approach. The directive takes full responsibility for generating and displaying the error messages within the element it's applied to.

To manipulate the content of the element, we have to inject the ElementRef provider, which gives us access to underlying DOM element. We then use this reference to update the element's content with the generated error messages.

The logic for generating the error messages within the directive is almost identical to that of the pipe. However, there's one key difference: we need to explicitly tell Angular to update the error messages when any of the input properties change. We achieve this by implementing the ngOnChanges lifecycle hook, which detects changes to input properties and triggers the updateErrorMessage method to refresh the displayed message.

This differs from the pipe, which is typically pure and re-executes automatically when its inputs change. In contrast, the directive needs the ngOnChanges hook to actively respond to updates in its input properties.

The choice between a pipe and a directive often depends on the desired level of control and flexibility. While pipes are simpler and more reusable for basic data transformations, directives offer direct DOM manipulation and fine-grained control over how and where error messages are displayed.

ERROR_MESSAGES provider

Another major change is how I handled the default error messages. Initially, they were defined in the pipe file, but this posed several drawbacks:

  • Separation of Concerns: The pipe should primarily focus on error message display logic, not on storing the actual messages. Separating the messages into a dedicated location improves code organization and adheres to the Single Responsibility Principle.
  • Testability: When error messages are embedded, it becomes harder to unit test the pipe in isolation. We can't easily test it with different sets of error messages without modifying the code.
  • Harder to Manage: As our application grows and the number of error messages increases, managing them within the pipe file can become cumbersome and clutter the code.
  • Limited Customization: If we want to modify the error messages for a particular component or use case, we'd have to duplicate the pipe and modify it, leading to code redundancy and potential inconsistencies. Additionally, having the error messages tightly coupled to the pipe makes it difficult to use the pipe in different parts of the application with varying error message requirements. If multiple components or directives define their own sets of error messages, we could end up with conflicting definitions for the same error codes, leading to unpredictable behavior.

To address these issues, we can create an injection token named ERROR_MESSAGES and define a provider for it. This allows us to provide different error message dictionaries at different levels of our application, offering greater flexibility and customization. The dictionary itself is defined in a separate file for better organization. I've also created a provider function, provideValidationErrorMessages(), for the providers array of the application configuration.

This file defines the ERROR_MESSAGES injection token and creates a provider function to be registered at the root level of the application:

In the app.config.ts file I've added the previously defined provider function provideValidationErrorMessages() to register the error message provider at the root level of the application:
The default error messages are now defined in a separate file:
This might seem like a lot of code to address the issues mentioned earlier, but it showcases the power and flexibility of Angular's Dependency Injection system. This approach allows us to easily manage and customize dependencies throughout our application, making our code more modular, maintainable, and testable.

Overriding default error messages

If we need to define a different dictionary for the default error messages in a specific component, we'll simply provide another value for the ERROR_MESSAGES token within that component's providers array:

By doing this, we effectively override the default messages provided at the application level. This allows us to customize the error messages specifically for this component. Angular's dependency injection system will then inject this new set of error messages into the ValidationErrorDirective whenever it's used within this component or its children.

Checking out the code

The code I've described so far can be found in the 13.validation-error-directive branch of the repository https://github.com/cezar-plescan/user-profile-editor/tree/13.validation-error-directive/src/app. Feel free to explore the repository and experiment with the directive yourself.

Comparison with the pipe

I want to analyze the directive and pipe approaches to determine which is better suited for our application.

  • Directive
    • Strengths:
      • DOM Manipulation: Directives directly manipulate the DOM, allowing us to modify the appearance, positioning, and content of the element displaying the error message. This offers greater flexibility for complex error visualizations.
      • Precise Targeting: We can apply the directive to specific form controls, providing fine-grained control over which elements display error messages.
      • Event Handling: Directives can easily react to events like focus, blur, or value changes, allowing us to update error messages dynamically as the user interacts with the form.
    • Weaknesses:
      • Learning Curve: Directives can be slightly more complex to understand and implement than pipes, especially for developers new to Angular.
      • Tight Coupling (Potentially): If not designed carefully, directives can become tightly coupled to the specific DOM structure of our form.
      • Testing: Unit testing directives can be more involved than testing pure pipes due to their interaction with the DOM.
  • Pipe
    • Strengths:
      • Simplicity: Pipes are generally easier to understand and use than directives, especially for simple data transformations.
      • Reusability: Pipes are highly reusable and can be used in various templates and contexts.
      • Testability: Pure pipes are easier to unit test than directives, as their logic is isolated from DOM manipulations.
      • Angular's Optimization: Angular's change detection mechanism is designed to optimize the use of pure pipes, potentially offering better performance in some cases.
    • Weaknesses:
      • Limited DOM Manipulation: Pipes cannot directly manipulate the DOM, so we'll need additional elements (like ) and potentially some conditional logic (e.g., *ngIf) in our template to display the error messages.
      • Less Precise Targeting: While we can conditionally apply the pipe based on error conditions, it might not be as precise as directly attaching a directive to a specific element.

Which is More Appropriate?

Pipe: The pipe is likely the more appropriate choice if our error messages are relatively simple strings and we want to prioritize reusability and ease of use. We can easily apply the pipe to multiple form controls within our template.

Directive: We can consider a directive if we need more complex error message displays (e.g., custom styling, conditional formatting, or rich UI elements) or if we need to perform more sophisticated DOM manipulations based on the error state.

The final answer depends on the application needs, but in many cases Angular applications tend to become larger and more complex and my choice would be to go with directives.

Additionally, we can combine both approaches:

  • Use the pipe to generate the error message string.
  • Use the directive to apply that message to the appropriate element (e.g., a tag) and potentially handle any additional styling or UI logic.

Using third-party libraries

While the custom directive and pipe I've implemented provide a solid foundation for displaying validation errors, more complex form scenarios might require additional features and flexibility. Thankfully, the Angular ecosystem offers several third-party libraries that that can make handling validation errors in our forms easier. I've found two popular libraries that could be useful:

  • ngx-errors: This library provides a declarative way to manage validation errors in your templates. It offers features like:
    • simplified syntax: we can define error messages directly within the template using simple directives.
    • dynamic error display: it automatically shows and hides error messages based on the validation status of the form controls.
    • customization: we can customize error messages for specific error types or create our own error templates.
  • @rxweb/reactive-form-validators: this library goes beyond basic validation and offers a comprehensive set of validators for various scenarios, such as:
    • complex validations: it includes validators for cross-field validation, conditional validation, and more.
    • customizable error messages: we can easily provide custom error messages for each validator.
    • internationalization (i18n): it supports multiple languages for the error messages.

Refactoring techniques

1. Extract Function/Method

Description

This refactoring technique is a fundamental principle in software development aimed at improving code organization, readability, and maintainability. The essence of this technique is to take a section of code within a larger function or method that performs a specific task and move it into its own separate function or method. This new function is given a descriptive name that clearly indicates its purpose.

More details at https://refactoring.guru/extract-method.

How to refactor

  1. Identify: Locate a block of code within a function that represents a cohesive unit of work (e.g., calculating a value, validating input, formatting data).
  2. Extract: Create a new function and move the identified code block into it.
  3. Replace: Replace the original code block with a call to the new function.
  4. Name: Give the new function a clear, descriptive name that accurately reflects its purpose.

Benefits

  • Improved Readability: Breaking down large functions into smaller, more focused functions makes the code easier to understand and follow.
  • Reduced Duplication: If the extracted code is used in multiple places, this technique eliminates the need to repeat it, leading to a cleaner codebase.
  • Enhanced Testability: Smaller functions are easier to test in isolation, as you can focus on specific inputs and expected outputs.
  • Increased Reusability: Extracted functions can be reused in other parts of our application, saving development time and effort.
  • Better Maintainability: The code becomes more modular, making it easier to modify and update without affecting other parts of the application.

Where it was used

  • The error message logic was extracted from the template into the pipe and the directive.
  • The getErrorMessage method has been extracted from the main generateErrorMessage method in the directive class. This improves readability and modularity, making the code easier to understand and maintain.

2. Replace Conditional with Polymorphism

Description

This technique addresses situations where we have conditional statements (e.g., if, else if, switch) that determine the behavior of a piece of code based on the type or properties of an object. The goal is to replace these conditionals with a more object-oriented approach using polymorphism.

Polymorphism allows objects of different classes to be treated as if they were of the same type. By creating a common interface or abstract class and then implementing specific behaviors in subclasses, we can eliminate the need for explicit conditional checks.

More details at https://refactoring.guru/replace-conditional-with-polymorphism.

How to refactor

  1. Identify the Conditional: Locate the conditional statement (or series of statements) that you want to refactor.
  2. Create a Common Interface/Class: Define an interface or abstract class that represents the common behavior or properties of the objects being tested in the conditional.
  3. Create Subclasses: For each branch of the conditional, create a subclass that implements the interface or extends the abstract class.
  4. Implement Behavior in Subclasses: Move the code from each branch of the conditional into the corresponding subclass, implementing the shared method or property defined in the interface/abstract class.
  5. Replace Conditional with Polymorphic Call: Replace the conditional statement with a call to the shared method or property on the object. The specific behavior will be determined at runtime based on the actual type of the object (polymorphism).

Benefits

  • Improved Readability: Code becomes more concise and easier to understand by eliminating explicit conditional checks.
  • Open/Closed Principle: The code becomes more extensible. We can add new behaviors (subclasses) without modifying the existing code that uses the interface/abstract class.
  • Reduced Complexity: Complex conditional structures are replaced with simpler polymorphic calls.
  • Better Maintainability: Changes to a specific behavior only require modifying the corresponding subclass, leaving other parts of the code unaffected.

Where it was used

The ERROR_MESSAGES injection token is a way that implements a form of polymorphism.

The default provider in our core module provides a default implementation of ERROR_MESSAGES. In UserProfileComponent, we override this provider, essentially providing a new implementation of ERROR_MESSAGES with custom error messages.

The ValidationErrorDirective injects the ERROR_MESSAGES token. Depending on where it's used, we have a polymorphic behavior:

  • If used in a component without a custom provider, it receives the default error messages.
  • If used in a component with a custom provider, it receives the customized messages.

In validation-error.directive.ts, the getErrorMessage method doesn't contain any explicit conditionals. Instead, it directly accesses the injected ERROR_MESSAGES object, which behaves polymorphically depending on the provider.

This polymorphic behavior is achieved through Angular's dependency injection system, which provides the appropriate ERROR_MESSAGES object at runtime based on the component's configuration.

More to come: Stay tuned for future form enhancements

This refactoring journey has demonstrated how small, deliberate improvements can lead to a more robust and maintainable Angular form. Refactoring is not just about fixing problems; it's about continuously evolving our code to make it more elegant, maintainable, and adaptable.

But there's more to come! Stay tuned for future articles where I'll tackle other refactoring strategies and unlock even more potential in your Angular development toolkit.

Top comments (0)