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;
}
}
}
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
}
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) {}
}
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) {
}
}
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;
}),
);
}
}
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) {
}
}
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;
}
}
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
RangeErrorrather than the generalErrorclass. Extending from more specific error types can improve code structure. In this case, the error genuinely is aRangeError, 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',
);
}
}
}
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.
