Back to home

Creating a Result Type in TypeScript

Published: Jul 14, 2024

Last updated: Jul 15, 2024

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:

  1. 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.
  2. Errors management in Rust: I really enjoy the Result type and the match syntax in Rust.
  3. 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:

export type Failure<E = unknown> = { _tag: "Failure"; error: E; }; export type Success<T = void> = { _tag: "Success"; data: T; }; export type Result<T = void, E = unknown> = Failure<E> | Success<T>; export function succeed<T = void>(data: T): Success<T> { return { _tag: "Success", data: data, }; } export function fail<E = unknown>(error: E): Failure<E> { return { _tag: "Failure", error: error, }; }

I figured that the Result type would be a union of Failure and Success types, which I could create with the fail and succeed functions:

const success = succeed("Hello, world!"); // Success<string> const failure = fail("Something went wrong"); // Failure<string>

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.

I use safeParse and safeParseAsync frequently, and the API looks little like the following:

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.

Going further into the handleResult source code, we look to work with the following:

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:

const Joi = require("joi"); const schema = Joi.object({ username: Joi.string().alphanum().min(3).max(30).required(), password: Joi.string().pattern(new RegExp("^[a-zA-Z0-9]{3,30}$")), repeat_password: Joi.ref("password"), access_token: [Joi.string(), Joi.number()], birth_year: Joi.number().integer().min(1900).max(2013), email: Joi.string().email({ minDomainSegments: 2, tlds: { allow: ["com", "net"] }, }), }) .with("username", "birth_year") .xor("password", "access_token") .with("password", "repeat_password"); schema.validate({ username: "abc", birth_year: 1994 }); // -> { value: { username: 'abc', birth_year: 1994 } } schema.validate({}); // -> { value: {}, error: '"username" is required' } // Also - try { const value = await schema.validateAsync({ username: "abc", birth_year: 1994, }); } catch (err) {}

Checking the validate source code, it uses the hapijs/hoek libraries assert function under the hood.

That too makes use of a AssertError class that extends Error:

"use strict"; const internals = {}; module.exports = class AssertError extends Error { name = "AssertError"; constructor(message, ctor) { super(message || "Unknown error"); if (typeof Error.captureStackTrace === "function") { // $lab:coverage:ignore$ Error.captureStackTrace(this, ctor); } } };

Valibot

Valibot was the last assertion library that I checked out. I personally haven't used Valibot much, but I've heard the hype.

Similar to Zod, Valibot has a safeParse function:

const EmailSchema = v.pipe(v.string(), v.email()); const result = v.safeParse(EmailSchema, "jane@example.com"); // SafeParseResult<TSchema> if (result.success) { const email = result.output; // string } else { console.log(result.issues); }

Issues look to be related to the BaseIssue interface, as well as making heavy use of the InferIssue utility function.

The type for SafeParseResult looks like the following:

import type { BaseIssue, BaseSchema, BaseSchemaAsync, InferIssue, InferOutput, } from "../../types/index.ts"; /** * Safe parse result type. */ export type SafeParseResult< TSchema extends | BaseSchema<unknown, unknown, BaseIssue<unknown>> | BaseSchemaAsync<unknown, unknown, BaseIssue<unknown>> > = | { /** * Whether is's typed. */ readonly typed: true; /** * Whether it's successful. */ readonly success: true; /** * The output value. */ readonly output: InferOutput<TSchema>; /** * The issues if any. */ readonly issues: undefined; } | { readonly typed: true; readonly success: false; readonly output: InferOutput<TSchema>; readonly issues: [InferIssue<TSchema>, ...InferIssue<TSchema>[]]; } | { readonly typed: false; readonly success: false; readonly output: unknown; readonly issues: [InferIssue<TSchema>, ...InferIssue<TSchema>[]]; };

Interestingly, the BaseIssue interface does not look to extend from Error:

/** * Base issue type. */ export interface BaseIssue<TInput> extends Config<BaseIssue<TInput>> { /** * The issue kind. */ readonly kind: "schema" | "validation" | "transformation"; /** * The issue type. */ readonly type: string; /** * The raw input data. */ readonly input: TInput; /** * The expected property. */ readonly expected: string | null; /** * The received property. */ readonly received: string; /** * The error message. */ readonly message: string; /** * The input requirement. */ readonly requirement?: unknown; /** * The issue path. * * TODO: Investigate if it is possible to make the path type safe based on the * input. */ readonly path?: [IssuePathItem, ...IssuePathItem[]]; /** * The sub issues. */ readonly issues?: [BaseIssue<TInput>, ...BaseIssue<TInput>[]]; }

I could be wrong about its usage. It was a little tricky navigating the source code for a library I am not too familiar with.

EffectTS

Finally, I went to check out what EffectTS does given how much their paradigm had inspired my initial search.

EffectTS has a Success and Error representation within the Effect namespace.

We can create a "Success" representation by invoking the Effect.succeed function:

import { Effect } from "@effect-ts/core"; const success = Effect.succeed("Hello, world!"); // Effect<string, never>

Although this represents a success, the type is in fact still Effect.

The source code for succeed is interesting (and a little trickier to grok):

/* @internal */ export const succeed = <A>(value: A): Effect.Effect<A> => { const effect = new EffectPrimitiveSuccess(OpCodes.OP_SUCCESS) as any; effect.effect_instruction_i0 = value; return effect; };

Looking into EffectPrimitiveSuccess led me to the alternative types EffectPrimitive and EffectPrimitiveFailure, where each look to have a similar implementation:

/** @internal */ class EffectPrimitiveSuccess { public effect_instruction_i0 = undefined; public effect_instruction_i1 = undefined; public effect_instruction_i2 = undefined; public trace = undefined; [EffectTypeId] = effectVariance; constructor(readonly _op: Primitive["_op"]) { // @ts-expect-error this._tag = _op; } [Equal.symbol](this: {}, that: unknown) { return ( exitIsExit(that) && that._op === "Success" && // @ts-expect-error Equal.equals(this.effect_instruction_i0, that.effect_instruction_i0) ); } [Hash.symbol](this: {}) { return pipe( // @ts-expect-error Hash.string(this._tag), // @ts-expect-error Hash.combine(Hash.hash(this.effect_instruction_i0)), Hash.cached(this) ); } get value() { return this.effect_instruction_i0; } pipe() { return pipeArguments(this, arguments); } toJSON() { return { _id: "Exit", _tag: this._op, value: toJSON(this.value), }; } toString() { return format(this.toJSON()); } [NodeInspectSymbol]() { return this.toJSON(); } [Symbol.iterator]() { return new SingleShotGen(new YieldWrap(this)); } }

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

  1. 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.
  2. ErrorType

    export type ErrorType = { _tag: string; [key: string]: any; };

    • ErrorType defines the structure for errors. Each error has a _tag (string identifier) and can have additional properties.
  3. 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

  1. 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.
  2. 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.
  3. 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.
  4. Transformations

    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>); }

    • map applies a function to the data if it's a success, otherwise returns the failure.
    • flatMap applies a function that returns a Result if it's a success, otherwise returns the failure.
  5. Equality Check

    equals(that: unknown): boolean { return ( that instanceof Result && this._tag === that._tag && this.value === that.value ); }

    • equals checks if another result is equal to the current one by comparing _tag and value.
  6. Serialization

    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(); }

    • toJSON converts the result to a JSON object.
    • toString returns a JSON string representation.
    • [Symbol.for("nodejs.util.inspect.custom")] customizes how the result is displayed in Node.js inspection.
  7. 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.

type TestError = | { _tag: "TestError1"; message: string } | { _tag: "TestError2"; message: string }; const result = Result.succeed(42); // Result<T, E extends ErrorType>.succeed<number>(data: number): Success<number> // 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", });

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.

Resources and further reading

Disclaimer: This blog post used AI to generate the images used for the analogy.

Photo credit: pawel_czerwinski

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on Visibuild.

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.

Creating a Result Type in TypeScript

Introduction

Share this post