DEV Community

Cover image for 25 lines Result Based Error Handling with TypeScript
Matthew Wang
Matthew Wang

Posted on

25 lines Result Based Error Handling with TypeScript

The problem with throwing errors

Let's say that we want to make a request to create a post.

The following piece of code may throw and Error and may not have been handled properly in the caller.

async function createPost(input: { post: Post }): Promise<unknown> {
  const _post = Post.parse(input.post); // ❗️ May throw an error
  const res = await fetch(`https://example.com/api/posts`, {
    method: "POST",
    body: JSON.stringify(_post),
    headers: {
      "Content-type": "application/json; charset=UTF-8",
    },
  }); // ❗️ May throw an error

  if (!res.ok) {
    throw new Error("Failed to create Post");
  }

  return await res.json(); // ❗️ May throw an error
}
Enter fullscreen mode Exit fullscreen mode

Try/Catch isn't Type Safe at the caller:

// infra/.../index.ts
// This can be anything that runs at the top level (e.g. a controller, lambda handler, CLI, React server component, server action, etc.)
async function run() {
  try {
    await createPost({
      post: {
        userId: 1,
        title: "Hello",
        body: "World",
      },
    });
  } catch (e) {
    //     ^ we don't know what type of error this is. 
    //       We can't easily handle different error types.
    console.error(e);
  }
}
Enter fullscreen mode Exit fullscreen mode

Result based solution

The result type solution is an idea borrowed from the functional programing world. The Success and Error cases need to be defined explicitly.

We want to treat Errors as outputs and ensure that we never throw errors.

Result input output diagram

Result Type Implementation

Here's what code using the result type may look like.

  function validateNumber(input: number): Result<number, Error> {
    const num = Math.random();
    if (num > 0.5) return Result.Success(num);
    return Result.Failure(new Error("Number too small"));
  }

  const res: Result<number, Error> = validateNumber(0.1);
  if (res.isOk) {
    console.log(res.value);
  } else {
    console.error(res.error); 
    /*
     * We still get the stack trace for where the error object was created
     *
     * Error: Number too small
     *  at validateNumber ...
     *  at main
     */
  }

Enter fullscreen mode Exit fullscreen mode

TypeScript will force us to check result.isOk before being able to access result.value. This makes use of discriminated unions.

Implement the Result type yourself with 25 lines of code.

This piece of code has been running throughout my production codebase. Feel free to copy and paste.

This solution is highly inspired by other functional TypeScript libraries.

I find including such libraries not worth it for simply wanting better error handling, especially if you work in a team.

// /utils/result.ts
export type Failure<E> = {
  isOk: false;
  error: E;
};

function Failure<E>(error: E): Failure<E> {
  return {
    isOk: false,
    error,
  };
}

export type Success<T> = {
  isOk: true;
  value: T;
};

function Success<T>(value: T): Success<T> {
  return {
    isOk: true,
    value,
  };
}

export type Result<T, E = never> = Success<T> | Failure<E>;
export const Result = {
  Failure,
  Success,
};

Enter fullscreen mode Exit fullscreen mode

The Result Object can be boiled down to the following structure.

Failure Object

{
  isOk: false,
  // E is the generic type of the error. Can be anything (Error, string, etc.
  error: E; 
}
Enter fullscreen mode Exit fullscreen mode

Success Object

{
  isOk: true,
  value: T; // T is the generic type of the value
}
Enter fullscreen mode Exit fullscreen mode

Full example with Result Type

Let's reimplement our createPost() function.

With the Result based solution, the errors and self-documenting and type-safe.

import { Result } from "#utils/result";
import z from "zod";
export * as API from "./apiWrapper";

// Custom error classes allows us to keep stack trace
export class StatusError extends Error {
  name = "StatusError" as const;
}

export class JsonError extends Error {
  name = "JsonError" as const;
}

export class ParseError extends Error {
  name = "ParseError" as const;
}

export class NetworkError extends Error {
  name = "NetworkError" as const;
}

export class ValidationError extends Error {
  name = "ValidationError" as const;
}

type Post = z.infer<typeof Post>;
const Post = z.object({
  userId: z.number().min(1),
  title: z.string().min(5),
  body: z.string().min(10),
});

export async function createPost(input: {
  post: Post;
}): Promise<
  Result<
    null,
    | UserDoesNotExistError
    | StatusError
    | JsonError
    | NetworkError
    | ValidationError
    | ParseError
  >
> {
  // This just remaps the zod schema error.
  const parsedPost = Post.safeParse(input.post);
  if (!parsedPost.success) {
    return Result.Failure(
      new ValidationError("Failed to validate Post input", {
        cause: parsedPost.error,
      })
    );
  }

  const _post = parsedPost.data;

  const userRes = await getUserFromId(_post.userId);
  if (!userRes.isOk) {
    // Notice how we can return this failure result directly because it is already a result type.
    return userRes;
  }

  if (userRes.value === null) {
    return Result.Failure(
      // It's good to include contextual information such as userId for better error messages.
      new UserDoesNotExistError(`UserId: ${_post.userId} does not exist`)
    );
  }

  const res = await fetch(`https://example.com/api/posts`, {
    method: "POST",
    body: JSON.stringify(_post),
    headers: {
      "Content-type": "application/json; charset=UTF-8",
    },
  });

  if (!res.ok) {
    return Result.Failure(new StatusError("Failed to create Post"));
  }

  let json;
  try {
    json = await res.json();
  } catch (e) {
    return Result.Failure(
      new JsonError("Failed to parse Post JSON", {
        cause: e,
      })
    );
  }

  const parsedResponse = PostDTO.safeParse(json);

  if (!parsedResponse.success) {
    return Result.Failure(
      new ParseError("Failed to validate Post response schema", {
        cause: parsedResponse.error,
      })
    );
  }

  return Result.Success(null);
}
Enter fullscreen mode Exit fullscreen mode

Pattern match your errors with switch case. Handle or throw them as you please.

💡 I generally just throw (Panic) for database errors since it indicates that something is horribly wrong. Only expected errors (Domain errors) will be passed to the failure path.

import { API } from "#apiWrapper";

// infra/.../index.ts
// This can be anything that runs at the top level (e.g. a controller, lambda handler, CLI, React server component, server action, etc.)
export default async function run(input: {
  userId: number;
  post: {
    title: string;
    body: string;
  };
}): Promise<{
  errorMessage?: string;
}> {
  const res = await API.createPost({
    post: {
      userId: input.userId,
      title: input.post.title,
      body: input.post.body,
    },
  });

  if (res.isOk) {
    return {};
  }

  captureError(res.error);

  // Handle different error types
  switch (res.error.name) {
    case "NetworkError":
      // We want to crash on network errors
      throw res.error;
    // Return generic error message for other errors as we don't want to expose internal error details
    case "UserDoesNotExistError":
      return {
        errorMessage: "User does not exist",
      };
    case "StatusError":
      return {
        errorMessage: "Failed to create post",
      };
    case "JsonError":
      return {
        errorMessage: "Failed to parse response",
      };
    case "ValidationError":
      return {
        errorMessage: "Invalid post input",
      };
    case "ParseError":
      return {
        errorMessage: "Failed to validate response",
      };
  }
}

// Can use telemetry to capture errors such as Sentry.io, OpenTelemetry, etc.
function captureError(e: unknown) {
  console.error(e);
  Sentry.captureException(e);
}
Enter fullscreen mode Exit fullscreen mode

💡 Note: You can also just use a generic Error type Result<..., Error>. This example just illustrates that you can include different error types.

Serializable Result

Using primitives for Values/Errors allows the Result Types to be serializable.

function validateUser(): Result<{userId: number}, "UserValidationError">
    ...
    return Result.Failure("UserValidationError");
}
Enter fullscreen mode Exit fullscreen mode

💡 Note: With strings as errors, we lose the stack trace.

Arguments against Result Type

If one forgets to handle the returned result. The Error can be lost.

It is best to also return the result or map the result error to another return value to avoid this caveat.

Needing wrappers for functions that throw.

This is a good tradeoff for production applications.

Code may look unfamiliar.

The actual result code is only 25 lines. If your teammates still prefer using try/catch, then yes. It is better to avoid the Result pattern.


References

Two Types of Errors. (2024). Effect Documentation. https://effect.website/docs/error-management/two-error-types/

‌Wlaschin, S. (2018). Domain modeling made functional: Tackle software complexity with domain-driven design and F#. Pragmatic Bookshelf.


Code used in article

Top comments (0)