Sergio Xalambrí

Result Objects in TS

When working with async code, one important thing we need to do is to handler errors, in TS (and JS) we typically use try/catch blocks of if using promises .catch to catch the error.

try {
  await doSomething();
} catch (error) {
  // do something with the error
}

doSomething().catch((error) => {
  // do something with the error
});

But, as we start to do more and more async things in the same function, and specially when we need to run them in parallel it starts to become a more complex.

For example, if we were creating a function to get all the data needed to render a page, we may need to run multiple functions in parallel to get the data each part of the page needs.

async function loader() {
  try {
    let user = await getUser(1);
    return new Response(JSON.stringify(user));
  } catch (error) {
    return new Response(errormessage, { status: 500 });
  }
}

And if something fail, we may need to keep rendering the rest of the page and just hide the failed section.

Enter Result objects. A Result is a type that can be either a Success or Failure. The idea is that your async functions shouldn't return a value or throw errors, instead they should return a Result.

This way, if an error happens, you know it's an actual exception, something that you were not expecting at all, but for errors you know could happen you will geite a Failure Result.

Let's see how to implement them. First we need to create what's a Success and what's a Failure.

export type Success<Value> = { status: "success"; value: Value };

export type Failure<Reason extends Error> = {
  status: "failure";
  reason: Reason;
};

So a Success is an object with status always as success and a value whose type that comes from a generic.

The Failure is simular, but status is failure and the reason is the error that happened.

Now we need to type the Result, which is a discriminated union of Success and Failure.

export type Result<Value, Reason extends Error = Error> =
  | Success<Value>
  | Failure<Reason>;

With this we could code an async function like this:

async function getUser(id: number): Promise<Result<User>> {
  try {
    let user = await db.users.find(id);
    return { status: "success", value: user };
  } catch (error) {
    return { status: "failure", reason: error as Error };
  }
}

But, creating those objects by hand is a bit verbose, so let's create functions.

export function Success<Value>(value: Value): Success<Value> {
  return { status: "success", value };
}

export function Failure<Reason extends Error>(reason: Reason): Failure<Reason> {
  return { status: "failure", reason };
}

Now we could update our code like this:

async function getUser(id: number): Promise<Result<User>> {
  try {
    let user = await db.users.find(id);
    return Success(user);
  } catch (error) {
    return Failure(error as Error);
  }
}

Now, if we go back to our code to load different parts of the page, we can use the Result to handle errors.

async function loader() {
  let user = await getUser(1);
  if (user.status === "failure") {
    return new Response(user.reason.message, { status: 500 });
  }
  return new Response(JSON.stringify(user.value));
}

Note how we know need to do result.status === "failure", or result.status === "success" to handle errors? This is is because TS will not know if the Result returned by getUser is a success or error until we check the status, since the success has a value and failure has a reason you can't access them until you know the status.

But we can do better, we can use TS assertions to check the status of the Result. We could, for example, throw if it's not a success or failure.

Success.assert = <Value, Reason extends Error>(
  result: Result<Value, Reason>
): asserts result is Success<Value> => {
  if (result.status === "failure") throw result.reason;
};

Failure.assert = <Value, Reason extends Error>(
  result: Result<Value, Reason>
): asserts result is Failure<Reason> => {
  if (result.status === "success") throw new Error("Expected Failure");
};

Now, if we can write our code like this:

async function loader() {
  let user = await getUser(1);
  // here we don't know if it's a success or failure
  Success.assert(user); // throw if it's a success
  // here user is a success result
  return new Response(JSON.stringify(user.value));
}

We can, of course do the inverse and use Failure.assert, but this put us back in the need for try/catch. So we can return a boolean instead.

Success.is = <Value, Reason extends Error>(
  result: Result<Value, Reason>
): result is Success<Value> => {
  return result.status === "success";
};

Failure.is = <Value, Reason extends Error>(
  result: Result<Value, Reason>
): result is Failure<Reason> => {
  return result.status === "failure";
};

And we can update our code this way:

async function loader() {
  let user = await getUser(1);
  if (Failure.is(user)) {
    return new Response(user.reason.message, { status: 500 });
  }
  return new Response(JSON.stringify(user.value));
}

So this is more similar to when we did user.status === "failure", one interesting thing is that if we stop sending an Error to the reason of failure and instead we send the error message, we can serialize the result object.

export type Failure = {
  status: "failure";
  reason: string;
};

export function Failure(error: Error): Failure {
  return { status: "failure", reason: error.message };
}

We can still pass the whole error, but we only use the error.message. And we can serialize it and send the Result object directly to the UI.

async function loader() {
  let user = await getUser(1);
  return new Response(JSON.stringify(user));
}

And, in our UI we can use the data to render something different.

function Screen() {
  // here data is the result, but we don't know if it's a success or failure
  let data = useLoaderData();
  if (Failure.is(data)) {
    // here we know it's a failure
    return <div>{data.reason}</div>;
  }
  // and here we know it's a success
  return <div>{data.value.name}</div>;
}

If you are only doing getting data from a single place, you should to check if it's a failure to return the proper status code. But if you need to load data from varios places to build a single screen, it may fail to load something but instead of breaking the whole screen, you can let individual parts fail.