No More try-catch: Bringing Rust's Result Type to TypeScript

richard_vanbergen

Richard Vanbergen

Posted on October 23, 2024

No More try-catch: Bringing Rust's Result Type to TypeScript

Ever since trying Rust for the first time I've been looking to replicate it's amazing error handling philosophy in TypeScript. I'm fully on board with the errors as values philosophy but until we get something like the safe assignment operator it's very hard to replicate this in JavaScript.

There's also the issue that even if the copy-pasted Rust's Result type into JS tomorrow there would always be libraries hanging around throwing random internal errors and not even properly documenting them.

Unfortunately, my home grown efforts fell short and didn't add enough value to justify the extra effort.

Fortunately, I found quite an innovative solution and aside from the syntax being a bit ugly due to the limitations of being a library instead of a language feature it actually works really well for a scalable error handling solution across your applications.

I'm talking about neverthrow and in this post I'll give it a bit of an introduction that I wish I had when I first came across it thanks to the great @mattpocockuk.

The Result type

At it's core the library imitates Rusts Result type. I won't go into it too heavily because this post will already by long and there's existing description in the documentation but just so we have an understanding: A Result is a container for two things, a value (T) and an error (E).

The error can be a union and I strongly recommend you come up with a standard format for your applications errors so they can be easily type narrowed later.

The Result object in neverthrow is a little different to Rust though. In Rust it's a simple struct because the language has features like pattern matching that make it useful.

We still don't have pattern matching in TS for some reason so for the time being Result is an object with a bunch of methods on it.

// get the result
const result = await getUserAppSettings("[insert uuid]")

// basic type narrowing
if (result.isOK()) {
  result.value // Settings
} else {
  // DrizzleGenericError | DrizzleTransactionRollbackError | UnknownError
  const error = result.error
  // this is why we wrapped it earlier
  if (error.name === "drizzle_transaction_rollback_error") {
    error // DrizzleTransactionRollbackError
  } else if (error.name === "drizzle_generic_error") {
    error // DrizzleGenericError 
  }
  // you get the idea...
}

// take a value and an error and convert it into a single type ("light" | "dark")
const theme = result.match(
  settings => settings?.theme === ("light" ? "light" : "dark") as const,
  // ignore the error just provide a default (dark >:D)
  () => "dark" as const
)

// "unwrapping" is extracting the value or error from the result object
// we can call `_unsafeUnwrap()` to get the value without type narrowing
// as the name implies this can throw errors
Enter fullscreen mode Exit fullscreen mode

You can see a full list of things you can do to your result in the API documentation but that's not what we're here for.

Making third party code safe

Drizzle is a great ORM. I really love it. But because there's no good error handling solution in JavaScript it does the standard try...catch thing and it does a pretty poor job of telling you what gets thrown where. (I had to go digging in the code).

With neverthrow we can make it better! The first step is to find out what can error when making a query. Drizzle seems to have two errors in its core. TransactionRollbackError and DrizzleError. I want to wrap these errors for ease of use. I will also add a custom DrizzleRecordNotFoundError so I don't have to check for nulls and a catch-all UnknownError.

import {
  TransactionRollbackError as TransactionRollbackErrorCause,
  DrizzleError as DrizzleErrorCause
} from "drizzle-orm"

// unknown catch-all error
export type UnknownError {
  error: "drizzle_record_not_found_error"
  message: string
  cause: unknown
}

// this is a custom error we'll throw when we find no
// records and we only want to return one record
export class DrizzleRecordNotFoundErrorCause extends Error {
}

export type DrizzleRecordNotFoundError {
  error: "drizzle_record_not_found_error"
  message: string
  cause: DrizzleRecordNotFoundErrorCause
}

// these errors wrap drizzle core errors with the `error` key
// so that they can be narrowed down as a discriminated union
export type DrizzleGenericError = {
  error: "drizzle_generic_error"
  message: string
  cause: DrizzleErrorCause
}

export type DrizzleTransactionRollbackError = {
  error: "drizzle_transaction_rollback_error"
  message: string
  cause: TransactionRollbackErrorCause
}

export type DrizzleError = DrizzleGenericError | DrizzleTransactionRollbackError | DrizzleRecordNotFoundError

export function createDrizzleError(cause: unknown) {
  if (cause instanceof TransactionRollbackErrorCause) {
    return {
      error: "drizzle_transaction_rollback_error",
      message: cause.message,
      cause: cause,
    } satisfies DrizzleTransactionRollbackError
  }

  if (cause instanceof DrizzleErrorCause) {
    return {
      error: "drizzle_generic_error",
      message: cause.message,
      cause: cause,
    } satisfies DrizzleGenericError
  }

  if (cause instanceof DrizzleRecordNotFoundErrorCause) {
    return {
      error: "drizzle_record_not_found_error"
      message: cause.message,
      cause: cause
    } satisfies DrizzleRecordNotFoundError
  }

  return {
    error: "unknown_error",
    message: "Unknown error",
    cause: cause,
  } as UnknownError
}
Enter fullscreen mode Exit fullscreen mode

That was a lot of templating I'll admit but it will be worth it. Now we need to actually make the database call and make sure it returns a proper Result type.

export async function getUserAppSettings(userId: string) {
  return ResultAsync.fromPromise(
    async () => {
      // make the db call, returns a `Settings[]`
      // but we want one and only one row
      const records = await db
        .select()
        .from(settingsTable)
        .where(eq(settingsTable.userId, userId))
        .execute()

      // getting a missing user settings is an error for our application
      // but drizzle doesn't and shouldn't see it that way, it just
      // returns an empty list, let's make a custom error
      if (result.length === 0) {
        throw new DrizzleRecordNotFoundErrorCause(`App settings for ${userId} not found`)
      }

      return result[0]
    }
    // the second parameter is what brings the errors under our control
    (e) => createDrizzleError(e)
  )
}
Enter fullscreen mode Exit fullscreen mode

We now have a safe function that returns a Result type. All we need to do in future is pass the createDrizzleError helper function and it will catch all possible errors for us and we can even add more.

Error propagation and unwrapping (neverthrows party trick)

The problem with the result type and forcing people to handle their errors like a good little dev is the error handling logic could get very verbose and repetitive. Leading to people cheating by calling methods like _unsafeUnwrap.

Rust has a solution for this, the ? error propagation operator. For those that don’t know Rust at all, the ? operator allows us to unwrap the values and if an error is encountered propagate up the call stack.

The practical application of this is that you don’t need to handle errors any time you call something that could error. You can simply delegate it for later and if everything’s good then we only have to code for the happy path.

Unfortunately because this is a library and not a language feature the syntax is a bit uglier than Rust's ? but it works very nicely once you get used to it.

The functions I’ll demonstrate here are safeTry and safeUnwrap. We’ll use generators to safely propagate errors while coding for the happy path.

Let’s imagine a function which first checks the users session, validates some input and then retrieves something from the database as demonstrated in the setup. We’ll assume we’ve properly wrapped the auth and validation functions to give use safe variants.

There’s a lot going on here in this code snippet, I’ll do my best to comment.

/**
 * Setup for demo purposes, skip to application code
 */

// import our zod or valibot schema
import { appSettingsSchema } from "./appSettings"

// import our wrapped parse and auth functions

// returns the parsed schema object or a `ValidationError`
import { safeParse } from "./validation"

// returns a { user, session } object or a `SessionError`
import { getValidatedSession } from "auth"

// returns a Settings object or `DrizzleGenericError | DrizzleTransactionRollbackError | UnknownError`
import { updateUserAppSettingsRepository } from "./user-repository"

/**
 * Application code
 */

async function updateUserAppSettings(unvalidatedInput: unknown) {
  // safeTry is what allows us to propogate errors
  const result = await safeTry(
    // async is cause we want to use async code, this isn't neccessary in all cases,
    // you can define a sync function here

    // function* defines this as a generator function where we'll `yield` out our errors
    async function* () {
      // validate the input, if there's a ValidationError then stop and propogate it up
      // otherwise we keep on the happy path :)
      const input = yield safeParse(appSettingsSchema, unvalidatedInput).safeUnwrap()

      // because this is an async function we need to wrap the async call in brackets
      const { user } = yield (await getValidatedSession()).safeUnwrap()

      // for the last call we just need to return the result directly
      return updateUserAppSettingsRepository(user.id, input)
    }
  )

  /**
   * Result<
   *   AppSettings,
   *   ValidationError | SessionError | DrizzleGenericError | DrizzleTransactionRollbackError | UnknownError
   * >
   */
  return result
}
Enter fullscreen mode Exit fullscreen mode

Notice how in the end, all the possible errors get gathered up into one place? Now lets see how we might consume it with some hypothetical API route logic:

const result = await updateUserAppSettings()

if (result.isOK()) {
  // set flash message and redirect back to index
} else {
  const error = result.error
  // validation errors should be set on the form,
  // everything else should be just reported as an error
  if (isValidationError(result.error)) {
    return new Response(JSON.stringify(error.issues), {
      status: 400
    })
  } else {
    // report is just an example, this post is already too long
    await report(error)
    return new Response(error.message, {
      status: 500
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

In my next post I’ll be writing about considerations in using with Next.JS. There’s some funky stuff around server and client components which can make it tricky. There’s also a discussion to be had about things like HTTP status error codes and server actions.

💖 💪 🙅 🚩
richard_vanbergen
Richard Vanbergen

Posted on October 23, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related