Error handling for Domain Modelling in TypeScript

by Giovanni Chiodi

7 min read

In functional programming languages, like F#, or in TypeScript using fp-ts, it's possible to achieve seamless composability of functions by controlling, manipulating and matching outputs with inputs. Most of functional programming trickeries serve actually this purpose, and the resulting composability opens the door to a treasure chest of patterns (see the Master Scott Wlaschin blog and book for this).

One of the ingredients for this power is not throwing errors, but instead returning them, because we always want to be intentional and in control of the output of any function, even if it's an error. And not all errors are equal, some are just logical parts of our domain logic (domain errors), some are runtime errors beyond our control (I/O errors).

In Node.js, instead, we like to throw errors like tomatoes in Bunyol. That compromises our ability to compose functions, hence our ability to implement a domain model, a.k.a. to be domain centric. Therefore, learning proper techniques for error-handling is an important requisite for domain modelling in TypeScript.

"In Node.js, instead, we like to throw errors like tomatoes in Bunyol. That compromises our ability to compose functions, hence our ability to implement a domain model, a.k.a. to be domain centric. Therefore, learning proper techniques for error-handling is an important requisite for domain modelling in TypeScript."

Sure, we could go full-functional with fp-ts. But as I explained in the previous lengthy post that's problematic for the average Node.js team. Instead, I want to explore solutions that only use plain TypeScript, and only the simple parts. I wish we could even disregard discriminant unions, that's already too complicated for me, but because TypeScript doesn't allow us to pattern-match types at runtime, we need to build in useful discriminant unions to help us out. Also, we'll need some basic knowledge of TypeScript generics. But that's all.

Let's start by generalising the output of a function, of any functions actually, in a general Result type. In a happy world without exceptions, functions results will always be a Success (when we're still tinkering about types we can use "unknown" to at least put them on the radar):

type Result = Success
type Success = unknown

But because we live in a cruel world with exceptions, Results can be either a Success or an Exception:

type Result = Success | Exception
type Success = unknown
type Exception = unknown

As we mentioned, Exceptions can either be logical failure conditions, which are known and part of our business logic (that we can call Failures), or I/O errors beyond our control (that we can call UnknownErrors):

type Result = Success | Exception
type Success = unknown
type Exception = Failure | UnknownError
type Failure = unknown
type UnknownError = unknown

It would be very nice if we could interpret whether a Result is a Success, a Failure or an UnknownError and handle the different cases accordingly, say, within a switch statement. As we mentioned, the only way to do that in TypeScript is using a discriminant union. That is to say, we need to build a common property (the discriminant), in each type (Success, Failure, and UnknownError types), with a different value in each to discriminate between them. If we call this property _d for "discriminant":

type Result = Success | Exception
type Success = { _d: "success" }
type Exception = Failure | UnknownError
type Failure = { _d: "failure" }
type UnknownError = { _d: "unknown_error" }

Great, now we could handle all case for example in a switch statement like so:

switch(result._d){
	case "success": handleSuccess(result)
	case "failure": hanldeFailure(result)
	case "unknown_error": handleError(result)
}

But what would those functions handle?? We didn't put yet anything else beside the discriminant in those types, so let's amend that next. For the Success type, we would need a data field to contain whatever data the successful function execution is supposed to return. For both the Exception types, we would need a cause field, that specifies what is the cause for the Exception. And for the UnknownError type we additionally need an error field to hold the specific Error object the runtime has thrown at us.

type Result = Success | Exception
type Success = { _d: "success", data: unknown }
type Exception = Failure | UnknownError
type Failure = { _d: "failure", cause: unknown }
type UnknownError = { _d: "unknown_error", cause: unknown, error: Error }

What type should the data field be in the Success type? Well, it depends on whatever type the function is supposed to return when it succeeds. We can use generics to represent this. We'll use a generic type S to say that the data field must be whatever type S is, and therefore the Result type is now not just a Result type but a Result type of type S:

type Result<S> = Success<S> | Exception
type Success = { _d: "success", data: S }
type Exception = Failure | UnknownError
type Failure = { _d: "failure", cause: unknown }
type UnknownError = { _d: "unknown_error", cause: unknown, error: Error }

So for a Result<string> we can expect a string in the data field of the Result's Success object, and so on. Using the same logic, the cause field for Failures should represent whatever we have identified as failures paths in our business logic. Maybe those are validation steps such as:

type DomainFailures = "invalid_email" | "invalid_username"

But again, these depend on the specific context, so we can use generics (a generic type F) to generalise what these failure causes might be:

type Result<S, F> = Success<S> | Exception<F>
type Success = { _d: "success", data: S }
type Exception<F> = Failure<F> | UnknownError
type Failure = { _d: "failure", cause: F }
type UnknownError = { _d: "unknown_error", cause: unknown, error: Error }

We could of course add more more data and details on failure causes. For example, both the data type for Success and the cause type for Failures could be additional discriminant unions, to allow switching deterministically between them for whoever is using the function. But let's keep it just as a list of causes for simplicity. As for the UnknownErrors, because they're beyond our control we have no way to know in advance what they might be, so we don't need a type for that. We can simply say that the cause for an UnknownError is a text message, a string:

type Result<S, F> = Success<S> | Exception<F>
type Success = { _d: "success", data: S }
type Exception<F> = Failure<F> | UnknownError
type Failure = { _d: "failure", cause: F }
type UnknownError = { _d: "unknown_error", cause: string, error: Error }

But what about async code? Well, in case of async/await, we'll just return a Promise of a Result:

type AsyncResult<S, F> = Promise<Result<S, F>>
type Result<S, F> = Success<S> | Exception<F>
type Success = { _d: "success", data: S }
type Exception<F> = Failure<F> | UnknownError
type Failure = { _d: "failure", cause: F }
type UnknownError = { _d: "unknown_error", cause: string, error: Error }

To make it easier to work with discriminant unions is always a good idea to create some helper functions to identify the different types and to create them:

type AsyncResult<S, F> = Promise<Result<S, F>>
type Result<S, F> = Success<S> | Exception<F>
type Success = { _d: "success", data: S }
const isSuccess = <S, F>(result: Result<S, F>): boolean => {
	return result._d === "success"
}
const succeed = <S>(data: S): Success<S> => {
	return { _d: "success", data}
}
const dataFrom = <S, F>(result: Result<S, F>): S => {
	return result['data']
}
type Exception<F> = Failure<F> | UnknownError
const isException = <S, F>(result: Result<S, F>): boolean => {
	return result._d !== "success"
}
const exceptionFrom = <S, F>(result: Result<S, F>): Exception<F> => {
	return result as Exception<F>
}
type Failure = { _d: "failure", cause: F }
const isFailure = <S, F>(result: Result<S, F>): boolean => {
	return result._d === "failure"
}
const fail = <F>(cause: F): Failure<F> => {
	return { _d: "failure", cause}
}
type UnknownError = { _d: "unknown_error", cause: string, error: Error }
const isUnknownError = <S, F>(result: Result<S, F>): boolean => {
	return result._d === "unknown_error"
}
const unknownError = (cause: string, error: Error): UnknownError => {
	return { _d: "unknown_error", cause, error}
}

If we use these types consistently in our code, and never throw errors but instead return them, we will always know exactly what a function returns, and we'll be able to handle separately the success case, the logical failure case, or the error case. This gives us a lot of leverage for function composability, even just for modelling. And we can wrap any function, sync or async, to have a consistent result type, for example:

//an existing method for using an external API
type ExternalApiCall = (params: CallParams) => Promise<ApiResponse>

//we wrap it in to another function that uses our AsyncResult type
type _ExternalApiCall = 
	(params: CallParams) => AsyncResult<ApiResponse, ApiFailure>

//we keep track of logical failures, such as valiation exceptions, or maybe we want to discard some responses, for example values less than 10 for a qty field
type ApiFailure = "invalid_params" | "qty_less_than_10"

//implementation of the wrapped funtion
const externalApiCall: _ExternalApiCall = 
	async (params: CallParams) => {
		//check for invalid business logic
		if(!paramsValid(CallParams)){
			//return a failure object
			return fail("invalid_params")
		}
		try{
			const apiResponse = await externalApiCall(CallParams)
			//discard values for qty < 10 (immaginary condition)
			if(apiResponse && apiResponse.qty < 10){
				return fail("qty_less_than_10")
			}
			//return a success object
			return succeed(apiResponse)
		} catch(e){
			//catch and return unknown errors
			return unknownError('externalApiCall_error', e)
		}
	}

This simple setup might not be enough for proper function piping, but it might (it might, I'm still experimenting...) be enough for composing function with currying. If, for instance, I make sure all of my functions return either an AsyncResult or a Result type, then I can at least break down bigger workflows in smaller functions, pass them as dependency, and handle in the implementation all possible scenarios, something like this:

	type _ExternalApiCall = 
		(params: CallParams) => AsyncResult<ApiResponse, ApiFailure>
		
	type AnotherStepFn = 
		(params: AnotherParams) => 
			AsyncResult<AnotherSuccess, AnotherFailure>

	type OneMoreStepFn = 
		(params: OneMoreParams) => 
			Result<OneMoreSuccess, OneMoreFailure>

	type YetAnotherStepFn = 
		(params: YetAnotherParams) => 
			Result<YetAnotherSuccess, YetAnotherFailure>
	
	type DoStuffWorkflow = 
		(apiCallFn: _ExternalApiCall) =>
		(anotherStepFn: AnotherStepFn) =>
		(oneMoreStepFn: OneMoreStepFn) =>
		(yetAnotherStepFn: YetAnotherStepFn) =>
		async (cmd: DoStuffCommand) => 
				AsyncResult<StuffDoneEvent, DoStuffFailure>

                //when implementing this function, we can check for exceptions, and in case return them, or handle them more in detail

			if(isException(apiResponse)){
				return exceptionFrom(apiRepsonse)
			}

//and check for success and handle it
			if(isSuccess(anotherStepFnRes)){
				const data = dataFrom(anotherStepFnRes)
				//doStuff(data) etc.
			}

It seems to work with some use cases I have, but more on the implementation next time.

CREDITS: I got inspired about this today coming across two excellent articles by Antti Pitkänen: this one, and this one. Great work Antti!!