Update July 15th 2024: I've ended up removing the `matchTag` functionality and some types in the previous version to simplify things a little more. The updated version will be it's own section at the bottom.
Overview
When it comes to error management, there are three things that I've been thinking about recently that I really like:
Errors management in Go: I am a big fan of the way Go drives me to handle errors in a very explicit way, right after calling.
Error management in EffectTS: Thinking about errors are expected or unexpected in EffectTS is actually a great paradigm shift as a TypeScript programmer.
In particular with EffectTS, I think their approach to error handling is a fantastic foundation for writing scalable TypeScript.
At my company, I've run into some recent problems that I think EffectTS would solve quite nicely. However, achieving buy-in for it at this point in time is difficult (I won't elaborate on the reasons) and in earnest, I am only hoping to reap the benefits of a few adopted features at this point in time.
So, I've been thinking about how I could implement my own Result type in TypeScript that could nicely return "successes" or "failures" that gives me nice TypeScript intellisense and type checking around errors. The aim would be to have something relatively simple that could be easily adopted at work, but also easily removed if need be.
In this blog post, we will take a look at libraries such as EffectTS, Zod, Valibot and Joi to understand how they approach a Result-like type and how we could approach building our own.
What is a Result type?
As far as I am talking about, I am looking to write a Result type is a type that can represent either a success or a failure.
I want the success type to return data (hopefully via a property like data) and a way to known it's a success, while the failure can safely return an error without throwing an exception.
The aim for failure types is that they will represent the "expected errors" i.e. errors that we expect could happen. This gives me power over how I handle these errors and how I can recover from them. It also can give me great type checking around these errors to improve developer experience.
Anything unexpected (also known as a defect in EffectTS) would be the errors that do end up in the catch part of the try/catch block. My aim here is that this is the stuff that we pay close attention to and aim to patch when these unexpected errors show their face.
Originally, I had a very simple implementation of a Result type that looked like this:
That being said, it felt super basic and like it was missing some stuff that I wanted, so I decided to snoop into some source code and see how the pros do it.
Zod
I started with Zod due to the amount of usage I spend with it on my personal work.
const result = stringSchema.safeParse("billie");
if (!result.success) {
// handle error then return
result.error;
} else {
// do something
result.data;
}
The API is very simple and intuitive, so it gets brownie points from me.
Looking into the source code for safeParse and safeParseAsync, both we returning Promise<SafeParseReturnType<Input, Output>> which is the return type of the handleResult function.
const handleResult = <Input, Output>(
ctx: ParseContext,
result: SyncParseReturnType<Output>
):
| { success: true; data: Output }
| { success: false; error: ZodError<Input> } => {
if (isValid(result)) {
return { success: true, data: result.value };
} else {
if (!ctx.common.issues.length) {
throw new Error("Validation failed but no issues detected.");
}
return {
success: false,
get error() {
if ((this as any)._error) return (this as any)._error as Error;
const error = new ZodError(ctx.common.issues);
(this as any)._error = error;
return (this as any)._error;
},
};
}
};
The unsuccessful route returns a ZodError, which both the docs and ZodError source code show that it is a class that extends Error.
Although it makes perfect sense for Zod's use case, I'm hoping to return a more generic class for my expected errors that do not extend Error. There reason being is that I don't want those errors to be throwable.
Joi
Next, I took a look at Joi, another validation library which is the one heavily used at my current company.
The validate function from Joi is what runs the assertions:
Looking into EffectPrimitiveSuccess led me to the alternative types EffectPrimitive and EffectPrimitiveFailure, where each look to have a similar implementation:
I won't explain too much of this (since it is a tough for me to explain with 100% confidence), but my big assumption here is that the implementation here is required for some important work under the hood, and so for my use case I am likely looking to use something that is simpler in terms of naming conventions, but some of the symbol properties do look important for me to implement.
Although it's a bit of off scope, something else that is really impressive about EffectTS on top of this though is how they make use of readonly tags. This helps with their conditional logic a lot, and I am a fan of some of their patterns that make use of _tag. I am hoping to make use of this myself in the final result.
Implementing a Result type
After reviewing all of the different approaches, I ended up with the following implementation for Result:
export type Success<T> = Result<T, never>;
export type Failure<E extends ErrorType> = Result<never, E>;
export type ErrorType = {
_tag: string;
[key: string]: any;
};
type MatchCases<T, E extends ErrorType, U> = {
Success: (data: T) => U;
} & {
[K in E["_tag"]]: (error: Extract<E, { _tag: K }>) => U;
};
export class Result<T, E extends ErrorType> {
protected constructor(
readonly _tag: "Success" | "Failure",
protected readonly value: T | E
) {}
static succeed<T>(data: T): Success<T> {
return new Result("Success", data) as Success<T>;
}
static fail<E extends ErrorType>(error: E): Failure<E> {
return new Result("Failure", error) as Failure<E>;
}
isSuccess(): this is Success<T> {
return this._tag === "Success";
}
isFailure(): this is Failure<E> {
return this._tag === "Failure";
}
get data(): T {
if (this.isSuccess()) return this.value as T;
throw new Error("Cannot get data from a Failure");
}
get error(): E {
if (this.isFailure()) return this.value as E;
throw new Error("Cannot get error from a Success");
}
map<U>(f: (value: T) => U): Result<U, E> {
return this.isSuccess()
? Result.succeed(f(this.data))
: (this as unknown as Result<U, E>);
}
flatMap<U>(f: (value: T) => Result<U, E>): Result<U, E> {
return this.isSuccess() ? f(this.data) : (this as unknown as Result<U, E>);
}
equals(that: unknown): boolean {
return (
that instanceof Result &&
this._tag === that._tag &&
this.value === that.value
);
}
toJSON() {
return {
_tag: this._tag,
[this._tag === "Success" ? "data" : "error"]: this.value,
};
}
toString(): string {
return JSON.stringify(this.toJSON());
}
[Symbol.for("nodejs.util.inspect.custom")]() {
return this.toJSON();
}
static matchTag<T, E extends ErrorType, U>(
result: Result<T, E>,
cases: MatchCases<T, E, U>
): U {
if (result.isSuccess()) {
return cases.Success(result.data);
} else {
const errorHandler = cases[result.error._tag as keyof typeof cases];
if (errorHandler) {
return errorHandler(result.error as any);
}
throw new Error(`Unhandled error type: ${result.error._tag}`);
}
}
}
Let's break down the code.
Definitions and Types
Success and Failure Types
export type Success<T> = Result<T, never>;
export type Failure<E extends ErrorType> = Result<never, E>;
Success<T> represents a successful result, containing a value of type T.
Failure<E extends ErrorType> represents a failed result, containing an error of type E.
ErrorType defines the structure for errors. Each error has a _tag (string identifier) and can have additional properties.
MatchCases
type MatchCases<T, E extends ErrorType, U> = {
Success: (data: T) => U;
} & {
[K in E["_tag"]]: (error: Extract<E, { _tag: K }>) => U;
};
MatchCases is a utility type for handling different cases when matching on a Result. It includes:
A Success case for handling successful results.
Dynamically creates cases for each error _tag to handle specific error types.
Result Class
Constructor and Static Methods
export class Result<T, E extends ErrorType> {
protected constructor(
readonly _tag: "Success" | "Failure",
protected readonly value: T | E
) {}
static succeed<T>(data: T): Success<T> {
return new Result("Success", data) as Success<T>;
}
static fail<E extends ErrorType>(error: E): Failure<E> {
return new Result("Failure", error) as Failure<E>;
}
Result is a generic class representing either a success (Success<T>) or a failure (Failure<E>).
It has a constructor accepting _tag and value.
succeed and fail are static methods for creating instances of Success and Failure.
Type Guards
isSuccess(): this is Success<T> {
return this._tag === "Success";
}
isFailure(): this is Failure<E> {
return this._tag === "Failure";
}
isSuccess and isFailure are type guards to determine if the result is a success or failure.
Accessors
get data(): T {
if (this.isSuccess()) return this.value as T;
throw new Error("Cannot get data from a Failure");
}
get error(): E {
if (this.isFailure()) return this.value as E;
throw new Error("Cannot get error from a Success");
}
data and error are getters to access the value or error. They throw errors if accessed on the wrong type.
[Symbol.for("nodejs.util.inspect.custom")] customizes how the result is displayed in Node.js inspection.
Pattern Matching
static matchTag<T, E extends ErrorType, U>(
result: Result<T, E>,
cases: MatchCases<T, E, U>
): U {
if (result.isSuccess()) {
return cases.Success(result.data);
} else {
const errorHandler = cases[result.error._tag as keyof typeof cases];
if (errorHandler) {
return errorHandler(result.error as any);
}
throw new Error(`Unhandled error type: ${result.error._tag}`);
}
}
matchTag matches on the result's type and invokes the appropriate handler from cases for Success or the specific error type.
Summary
As you can tell, this does take a lot of inspiration from the EffectTS implementation, but I've tried to simplify it down to the core features that I need as well as using the other validation libraries as inspiration.
This code defines a Result class for handling operations that can either succeed or fail.
It includes type guards, transformations (map, flatMap), and utilities for pattern matching (matchTag).
Success and Failure are specific instances of Result.
ErrorType is a flexible error structure with a _tag for identifying error types.
This approach allows for clear and type-safe handling of operations that may succeed or fail, providing strong guarantees about the types involved.
The best way to get a feel for how this works is to look at the unit tests that I wrote for this type:
import { Result, ErrorType } from "./alt-result";
describe("Result", () => {
// Define some test error types
type TestError =
| { _tag: "TestError1"; message: string }
| { _tag: "TestError2"; message: string };
describe("creation and basic methods", () => {
it("should create a Success result", () => {
const result = Result.succeed(42);
expect(result.isSuccess()).toBe(true);
expect(result.isFailure()).toBe(false);
expect(result.data).toBe(42);
});
it("should create a Failure result", () => {
const error: TestError = { _tag: "TestError1", message: "Test error" };
const result = Result.fail(error);
expect(result.isSuccess()).toBe(false);
expect(result.isFailure()).toBe(true);
expect(result.error).toEqual(error);
});
it("should throw when accessing data of a Failure", () => {
const result = Result.fail({ _tag: "TestError1", message: "Test error" });
expect(() => result.data).toThrow("Cannot get data from a Failure");
});
it("should throw when accessing error of a Success", () => {
const result = Result.succeed(42);
expect(() => result.error).toThrow("Cannot get error from a Success");
});
});
describe("map and flatMap", () => {
it("should map a Success result", () => {
const result = Result.succeed(42).map((x) => x * 2);
expect(result.isSuccess()).toBe(true);
expect(result.data).toBe(84);
});
it("should not map a Failure result", () => {
const error: TestError = { _tag: "TestError1", message: "Test error" };
const result = Result.fail<TestError>(error).map((x) => x * 2);
expect(result.isFailure()).toBe(true);
expect(result.error).toEqual(error);
});
it("should flatMap a Success result", () => {
const result = Result.succeed(42).flatMap((x) => Result.succeed(x * 2));
expect(result.isSuccess()).toBe(true);
expect(result.data).toBe(84);
});
it("should not flatMap a Failure result", () => {
const error: TestError = { _tag: "TestError1", message: "Test error" };
const result = Result.fail<TestError>(error).flatMap((x) =>
Result.succeed(x * 2)
);
expect(result.isFailure()).toBe(true);
expect(result.error).toEqual(error);
});
});
describe("equals", () => {
it("should consider two Success results with the same data equal", () => {
const result1 = Result.succeed(42);
const result2 = Result.succeed(42);
expect(result1.equals(result2)).toBe(true);
});
it("should consider two Failure results with the same error equal", () => {
const error: TestError = { _tag: "TestError1", message: "Test error" };
const result1 = Result.fail(error);
const result2 = Result.fail(error);
expect(result1.equals(result2)).toBe(true);
});
it("should consider Success and Failure results not equal", () => {
const success = Result.succeed(42);
const failure = Result.fail({
_tag: "TestError1",
message: "Test error",
});
expect(success.equals(failure)).toBe(false);
});
});
describe("matchTag", () => {
it("should match Success case", () => {
const result = Result.succeed(42);
const output = Result.matchTag(result as Result<number, TestError>, {
Success: (data) => `Success: ${data}`,
TestError1: (error: TestError) => `Error1: ${error.message}`,
TestError2: (error: TestError) => `Error2: ${error.message}`,
});
expect(output).toBe("Success: 42");
});
it("should match Failure case", () => {
const result = Result.fail<TestError>({
_tag: "TestError1",
message: "Test error",
});
const output = Result.matchTag(result, {
Success: (data) => `Success: ${data}`,
TestError1: (error: TestError) => `Error1: ${error.message}`,
TestError2: (error: TestError) => `Error2: ${error.message}`,
});
expect(output).toBe("Error1: Test error");
});
it("should throw for unhandled error types", () => {
const result = Result.fail({ _tag: "UnhandledError" } as ErrorType);
expect(() =>
Result.matchTag(result as unknown as Result<number, TestError>, {
Success: (data) => `Success: ${data}`,
TestError1: (error: TestError) => `Error1: ${error.message}`,
TestError2: (error: TestError) => `Error2: ${error.message}`,
})
).toThrow("Unhandled error type: UnhandledError");
});
});
});
Updated approach (July 15th 2024)
After playing around a bit more, I've done some refactoring. Right now, my current implementation looks like this:
export type Success<T> = Result<T, never>;
export type Failure<E> = Result<never, E>;
/**
* !!! EXPERIMENTAL !!!
* The following class provides a way to enapsulate the result of an operation that can either succeed or fail.
* It enables the use of functional programming techniques to handle the result of an operation. This can
* be useful in TypeScript for handling expected errors in a more type-safe way.
*
* @experimental
* @see https://blog.dennisokeeffe.com/blog/2024-07-14-creating-a-result-type-in-typescript
*
* @example
* type TestError =
* | { _tag: "TestError1"; message: string }
* | { _tag: "TestError2"; message: string };
*
* // Result<T, E extends ErrorType>.succeed<number>(data: number): Success<number>
* const result = Result.succeed(42);
*
* // Result<T, E extends ErrorType>.fail<{
* // _tag: "TestError1";
* // message: string;
* // }>(error: {
* // _tag: "TestError1";
* // message: string;
* // }): Failure<{
* // _tag: "TestError1";
* // message: string;
* // }>
* const failure = Result.fail<TestError>({
* _tag: "TestError1",
* message: "Test error",
* });
*/
export class Result<T, E> {
protected constructor(
readonly _tag: "Success" | "Failure",
protected readonly value: T | E
) {}
static succeed<T>(data: T): Success<T> {
return new Result("Success", data) as Success<T>;
}
static fail<E>(error: E): Failure<E> {
return new Result("Failure", error) as Failure<E>;
}
isSuccess(): this is Success<T> {
return this._tag === "Success";
}
isFailure(): this is Failure<E> {
return this._tag === "Failure";
}
get data(): T {
if (this.isSuccess()) return this.value as T;
throw new Error("Cannot get data from a Failure");
}
get error(): E {
if (this.isFailure()) return this.value as E;
throw new Error("Cannot get error from a Success");
}
map<U>(f: (value: T) => U): Result<U, E> {
return this.isSuccess()
? Result.succeed(f(this.data))
: (this as unknown as Result<U, E>);
}
flatMap<U>(f: (value: T) => Result<U, E>): Result<U, E> {
return this.isSuccess() ? f(this.data) : (this as unknown as Result<U, E>);
}
equals(that: unknown): boolean {
return (
that instanceof Result &&
this._tag === that._tag &&
this.value === that.value
);
}
toJSON() {
return {
_tag: this._tag,
[this._tag === "Success" ? "data" : "error"]: this.value,
};
}
toString(): string {
return JSON.stringify(this.toJSON());
}
[Symbol.for("nodejs.util.inspect.custom")]() {
return this.toJSON();
}
}
I've removed the matchTag static method as in introduced unnecessary complexity that I think I wanted to think through more. Instead, I'm relying on the _tag property to handle the matching.
I've removed the ErrorType type as it introduced unnecessary complexity. I'm happy to just use E as the error type for now to see how it goes.
There is also another approach I am mulling over to reduce some as casting for the following:
static succeed<T>(data: T): Success<T> {
return new Result("Success", data) as Success<T>;
}
static fail<E>(error: E): Failure<E> {
return new Result("Failure", error) as Failure<E>;
}
That approach would be to convert Result to an abstract class and convert the Success and Failure types into sub classes:
export abstract class Result<T, E> {
protected constructor(
readonly _tag: "Success" | "Failure",
protected readonly value: T | E
) {}
static succeed<T>(data: T): Success<T> {
return new Success(data);
}
static fail<E>(error: E): Failure<E> {
return new Failure(error);
}
abstract isSuccess(): this is Success<T>;
abstract isFailure(): this is Failure<E>;
get data(): T {
if (this.isSuccess()) return this.value;
throw new Error("Cannot get data from a Failure");
}
get error(): E {
if (this.isFailure()) return this.value;
throw new Error("Cannot get error from a Success");
}
map<U>(f: (value: T) => U): Result<U, E> {
return this.isSuccess()
? Result.succeed(f(this.data))
: (this as unknown as Result<U, E>);
}
flatMap<U>(f: (value: T) => Result<U, E>): Result<U, E> {
return this.isSuccess() ? f(this.data) : (this as unknown as Result<U, E>);
}
equals(that: unknown): boolean {
return (
that instanceof Result &&
this._tag === that._tag &&
this.value === that.value
);
}
toJSON() {
return {
_tag: this._tag,
[this._tag === "Success" ? "data" : "error"]: this.value,
};
}
toString(): string {
return JSON.stringify(this.toJSON());
}
[Symbol.for("nodejs.util.inspect.custom")]() {
return this.toJSON();
}
}
export class Success<T> extends Result<T, never> {
constructor(data: T) {
super("Success", data);
}
isSuccess(): this is Success<T> {
return true;
}
isFailure(): this is Failure<never> {
return false;
}
get data(): T {
return this.value;
}
}
export class Failure<E> extends Result<never, E> {
constructor(error: E) {
super("Failure", error);
}
isSuccess(): this is Success<never> {
return false;
}
isFailure(): this is Failure<E> {
return true;
}
get error(): E {
return this.value;
}
}
Both approaches should only require removal of the matchTag tests that I had previously.
Conclusion
At the moment, I am content with this implementation. As you'll see in some upcoming posts, it balances the complexity of the implementation with it's use-case power.
Please note that this is still early days and a little raw, which the end-game meaning to be a useful utility that might be a stepping stone towards something more powerful.
If you find some obvious improvements or have some feedback, please feel free to reach out to me on Twitter. I would be very keen to hear your thoughts.