Original cover photo by Brett Jordan on Unsplash.

Why Errors Deserve Attention

Developers tend to steer clear of errors, and that instinct is especially strong among those just starting out. Yet errors are a natural part of building software and can even work in our favor. The key is in how we respond to them. A well-crafted error message, one that pinpoints the issue and hints at a fix, is far more valuable than a vague failure.

Angular applications are no exception to this rule. This post walks through practical ways to manage errors in Angular, how to tell different error types apart, and when it makes sense to raise custom errors of our own.

Centralize Error Handling in a Service

Placing specialized logic such as logging or error handling inside an Angular service is a sensible pattern. The service needs to be adaptable, accommodating various error shapes. For the sake of this example, let's separate errors originating from HTTP requests from other system-level problems. Below is an outline of such a service:

import { Injectable } from "@angular/core";
import { HttpErrorResponse } from "@angular/common/http";

enum HttpErrorCodes {
  BadRequest = 400,
  Unauthorized = 401,
  Forbidden = 403,
  ServerError = 500,
}

@Injectable()
export class ErrorService {
  handleError(error: Error) {
    if (error instanceof HttpErrorResponse) {
      this.handleHttpError(error);
    } else {
      // other handling
      console.error("Error:", error);
    }
  }

  private handleHttpError(error: HttpErrorResponse) {
    switch (error.status) {
      case HttpErrorCodes.BadRequest:
        // handle bad request error
        break;
      case HttpErrorCodes.Unauthorized:
        // handle unauthorized error
        break;
      case HttpErrorCodes.Forbidden:
        // handle forbidden error
        break;
      case HttpErrorCodes.ServerError:
        // handle server error
        break;
      default:
        // handle other http error
        break;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This basic service covers a range of errors, drawing a line between HTTP failures and others. To extend its usefulness, imagine integrating a third-party error tracking tool. The service can conditionally invoke that tool based on the current environment:

export class ErrorService {
  constructor(
    private readonly environment: Environment,
    private readonly logger: LoggerService
  ) {}

  handleError(error: Error) {
    if (error instanceof HttpErrorResponse) {
      this.handleHttpError(error);
    } else {
      // other handling
      console.error("Error:", error);
    }

    // now, let's check the environment, and,
    // when in production, send the error info to our logging tool:
    if (this.environment.production) {
      this.logger.logError(error);
    }
  }

  // other methods
}
Enter fullscreen mode Exit fullscreen mode

With this service in place, we're ready to explore various error-handling scenarios.

Raising Errors When We Detect a Problem

There are times when an HTTP request succeeds with a 200 status, yet the payload indicates something went wrong. For instance, asking for a user that doesn't exist might yield a 404, or it could return a body like { "error": "User not found" }. If you're working with Observables, typical error operators may not catch this:

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
})
export class AppComponent {
  user$ = this.userService.getUser().pipe(
    catchError((error) => {
      // this WILL NOT work!
      // handle the error
      return of(null);
    })
  );

  constructor(private readonly userService: UserService) {}
}
Enter fullscreen mode Exit fullscreen mode

You might assume the error was handled, but if the API responds with a 200 OK and a message in the body explaining the restriction, no 404 is triggered, so the error handler stays silent. How do we address this? One option is to add checks inline:

@Component({
  ...
})
export class AppComponent {
  user$ = this.userService.getUser().pipe(
    map(response => {
      if (response.success) {
        return response.data;
      } else {
        return throwError(error);
      }
    }),
  );

  constructor(private readonly userService: UserService) {
  }
}
Enter fullscreen mode Exit fullscreen mode

Remember, this "false success" pattern could repeat across multiple API calls, leading to duplicated logic scattered throughout the codebase. A custom RxJS operator could help, but it too would need to be imported in every relevant place. A cleaner approach is to centralize this with an HttpInterceptor, which can inspect responses and throw an error when needed:

export class ResponseErrorInterceptor implements HttpInterceptor {
  intercept(
    req: HttpRequest<any>,
    next: HttpHandler,
  ): Observable<HttpEvent<any>> {
    return next.handle(req).pipe(
      map((event: HttpEvent<any>) => {
        if (event instanceof HttpResponse) {
          if (
            event.body?.hasOwnProperty('success') &&
            !event.body.success
          ) {
            throw new HttpErrorResponse({
              status: 400,
              url: event.url,
              error: event.body.error ?? 'Unknown Error',
            });
          }
        }
        return event;
      }),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This interceptor checks a success flag on the response, but the logic can be adapted to any condition. Multiple interceptors can be stacked to handle different scenarios. With this in place, the catchError operator becomes effective:

@Component({
  ...
})
export class AppComponent {
  user$ = this.userService.getUser().pipe(
    catchError(error => of(handleError(error))),
  );

  constructor(private readonly userService: UserService) {
  }
}
Enter fullscreen mode Exit fullscreen mode

Guarding Data Integrity

TypeScript's type system doesn't always guarantee valid data. This is especially true for Angular pipes, where the input must meet specific criteria or the output will be unreliable. For example, a pipe that looks up a user by ID needs to verify the input is not just a number, but also a positive integer. Without this check, an invalid value could slip through and produce a misleading result.

To protect both our own code and that of other developers, we can validate the input and throw an error when it's invalid:

class IdError extends RangeError {
    constructor(providedNumber: number) {
        super(
          `Provided number ${providedNumber} is not a valid id. 
           It must be a positive integer.`);
    }
}

@Pipe({
    name: "findUserById",
})
export class FindUserByIdPipe implements PipeTransform {
    transform(users: User[], id: number): User {
        if (!(Number.isInteger(id) && id > 0)) {
            throw new IdError(id);
        }
        const user = users.find(user => user.id === id);
        return user;
    }
}
Enter fullscreen mode Exit fullscreen mode

This error message clearly points to the flawed input, sparing us from debugging a subtle and confusing issue later on.

Observe that we extend from RangeError rather than the general Error class. Extending from more specific error types can improve code structure. In this case, the error genuinely is a RangeError, making that class the right choice for inheritance.

Flagging Design Concerns

Errors can also serve as a communication tool for developers. Consider a component designed to accept one input OR another, but not both. Using them together might lead to unforeseen behavior. In such a scenario, it's wise to throw an error to immediately inform the developer of the misuse:

class ComponentInputCompatibilityError extends Error {
    constructor(...properties: string[]) {
        super(`The following properties cannot be provided
               simultaneously: ${properties.join(', ')}`);
    }
}

@Component({
  ...
})
export class MyComponent {
    @Input() property: string;
    @Input() otherProperty: number;

    ngOnChanges(changes: SimpleChanges) {
        if (this.property && this.otherProperty) {
            throw new ComponentInputCompatibilityError(
                'property',
                'otherProperty',
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach ensures nobody trips over a confusing bug caused by misunderstanding the component's API.

Warning: This pattern might signal a deeper design flaw. Ideally, the code should be refactored to prevent this situation altogether. However, when a large refactor isn't feasible, a clear and descriptive error becomes essential.

Wrapping Up

Errors are a valuable ally in understanding and maintaining our code. As shown, Angular apps present various scenarios where we either need to catch errors or generate them ourselves. Keep this guide handy whenever you're writing code that could go wrong.