Select Page

Using Type Guards To Narrow Down Error Handling Types In Angular 14

Cyberdime
Published: September 19, 2022

Over the weekend, I added an Angular 14 front-end to my Strangler feature flag exploration in Lucee CFML. However, something wasn’t sitting right with me: Error handling. In TypeScript, the type of an error variable within a catch block (or Promise callback) is always any. This makes for relatively easy error handling; but, it side-steps the type safety normally provided by the compiler. As such, I wanted to go back and add a Type Guard with a Type Predicate that will help my error handling workflow narrow down the value being caught.

ASIDE: Apparently, as of TypeScript 4, there is now an unknown type which is being used for error handling. I have not personally tried that as I am a few years behind on my TypeScript skills.

In my Angular 14 application, I have an ApiClient class that proxies the HttpClient and is responsible for making requests to the API end-point of my Lucee CFML server. On the Lucee / ColdFusion side of the network, I centralize my error handling and error message translation such that the API will return a message that is safe to show the user. This way, I don’t have to recreate all of the error handling logic on both the Server and the Client.

Now, as part of the request workflow in my ApiClient, I intercept error responses and translate them into predicable – and safe – error structures that can be consumed in the rest of the application. Part of this process entails plucking the aforementioned user friendly error message out of the Server response and making it available to the Angular application.

Here’s the private method, normalizeError(), that takes the error returned by the HttpClient and makes sure that it has a developer-friendly shape:

export class ApiClient {
	// ... truncated ....
	/**
	* I normalize the given error to have a predictable shape.
	*/
	private normalizeError( errorResponse: any ) : ResponseError {
		// Setup the default structure.
		// --
		// NOTE: The "isApiClientError" property is a flag used in other parts of the
		// application to facilitate type guards, type narrowing, and error consumption.
		var error = {
			isApiClientError: true,
			data: {
				type: "ServerError",
				message: "An unexpected error occurred while processing your request.",
				rootCause: null
			},
			status: {
				code: ( errorResponse.status || 0 ),
				text: ( errorResponse.statusText || "" )
			}
		};
		// If the error data is an Object (which it should be if the server responded
		// with a domain-based error), then it should have "type" and "message"
		// properties within it. That said, just because this isn't a transport error, it
		// doesn't mean that this error is actually being returned by our application.
		if (
			( errorResponse.error?.strangler === true ) &&
			( typeof( errorResponse.error?.type ) === "string" ) &&
			( typeof( errorResponse.error?.message ) === "string" )
			) {
			error.data.type = errorResponse.error.type;
			error.data.message = errorResponse.error.message;
		// If the error data has any other shape, it means that an unexpected error
		// occurred on the server (or somewhere in transit, such as at the CDN, Ingress
		// Proxy, Load Balancer, etc). Let's pass that raw error through as the rootCause,
		// using the default error structure.
		} else {
			error.data.rootCause = errorResponse.error;
		}
		return( error );
	}
}

Ultimately, if the server returns an error response, and the .strangler flag is set to true, it means that the message property embedded within the error response is safe to show the user. Of course, there are many reasons why an HTTP request may fail, having nothing to do with my ColdFusion application’s error handling. As such, I have to inspect the HttpClient error for said flag before I attempt to extract the user friendly error message.

Now, even with this predictable shape, the catch blocks and Promise handlers still use any for the error object because there’s no guarantee as to where an error came from within the call-stack. But, within the catch blocks, we can apply “type narrowing” techniques in order to add runtime predictability.

“Narrowing” is the process by which TypeScript refines a given value to be of a more specific type. So, in a catch block, we can narrow the error object from the type any down to the type ResponseError, which is being returned by my ApiClient. And to do this, we’re going to use a Type Guard.

A Type Guard is a function whose return type is a Type Predicate. A type predicate takes the form of:

parameterName is Type

And, when this Type Guard function returns true, it tells TypeScript that the Type Predicate can be applied and that the value passed to the given guard function is guaranteed to be of the given type. As a result, TypeScript is able to narrow the error type from any down to the type provided by the Type Guard.

In my ApiClient class, I am providing a member method that can be used as a type guard – notice that I am using the isApiClientError flag being provided by the error-normalization method above:

export class ApiClient {
	// .... truncated ....
	/**
	* By default, errors in a catch block are of type "any" because it's unclear where in
	* the callstack the error was thrown. This method provides a runtime check that
	* guarantees that the given error is an API Client error. When this method returns
	* "true", TypeScript will narrow the error variable to be of type ResponseError.
	*/
	public isApiClientError( error: any ) : error is ResponseError {
		return( error?.isApiClientError === true );
	}
}

Now, within my error handling workflow, I can use this isApiClientError() type guard method before attempting to extract the user-friendly error message provided by my ColdFusion server. In this Angular 14 application, I’m implementing this process in the getMessage() method of my ErrorService (which overrides the core implementation of Angular’s ErrorHandler):

export class ErrorService implements ErrorHandler {
	private apiClient;
	/**
	* I initialize the API client with the given dependencies.
	*/
	constructor( apiClient: ApiClient ) {
		this.apiClient = apiClient;
	}
	// ---
	// PUBLIC METHODS.
	// ---
	/**
	* I attempt to extract the human-friendly error message from the given error. However,
	* since there are no guarantees as to where in the application this error was thrown,
	* we will have to do some introspection / type narrowing in order to find the most
	* appropriate error message property to display.
	*/
	public getMessage( error: any ) : string {
		// If this is an API Client error, the embedded message is trusted and can be
		// rendered for the user.
		if ( this.apiClient.isApiClientError( error ) ) {
			return( error.data.message );
		}
		return( "Sorry, we could not process your request." );
	}

	/**
	* I provide a centralized location for logging errors in Angular.
	*/
	public handleError( error: any ) : void {
		// NOTE: In the future, this could ALSO be used to push the errors to a remote log
		// aggregation API end-point or service.
		console.error( error );
	}
	
}

As you can see, in the getMessage() method, I’m using the isApiClientError() method in order to narrow down the error type before stepping into the complex data structure for the human-friendly error message provided by the ColdFusion API.

Now, in my Component-level form processing and error handling, I can catch any error and easily – and safely – hand it off to this getMessage() method:

export class CreateViewComponent {
	// .... truncated ....
	/**
	* I submit the new feature flag for processing.
	*/
	public createFeatureFlag() : void {
		if ( this.isProcessing ) {
			return;
		}
		this.isProcessing = true;
		this.errorMessage = null;
		this.featureFlagService
			.createFeatureFlag(/* ... form data ... */)
			.then(
				( response ) => {
					this.router.navigate([ "/feature-flag", this.form.key ]);
				},
				( error ) => {
					this.isProcessing = false;
					// We're taking the error message, which is currently of
					// type `any`, and we're handing it off to the ErrorService,
					// which will NARROW THE TYPE DOWN to the `ErrorResponse`
					// structure returned by the ApiClient. This allows the
					// ErrorService to safely extract the user-friendly error
					// message returned by the ColdFusion API.
					this.errorMessage = this.errorService.getMessage( error );
				}
			)
		;
	}
}

Fundamentally, my Angular application’s runtime behavior is no different than it was before. But now, I have better error handling in place through type guards and type narrowing. This illustrates the primary value of TypeScript (for me): that it forces me to think more deeply about how my code is being run.

Want to use code from this post?
Check out the license.

Source: www.bennadel.com