DEV Community

Cover image for TypeScript RPC Error Handling and Result<Ok, Err>
DJ NUO (Alex Nesterov)
DJ NUO (Alex Nesterov)

Posted on

TypeScript RPC Error Handling and Result<Ok, Err>

Why?

  • Writing for myself to structure thoughts & not go insane.
  • Hunt for the "perfect type-safe backend-to-frontend solution".
  • I wrote an API in Hono that returned a very distinct set of errors (and data). And wanted to utilize these types on the frontend to show valuable messages to the users.

I'm just a self-taught developer in search for "best industry practices"

Credits

Inspired by:

Intro


It would be fair to separate the types of fetch() errors into two groups:

  1. Controlled errors. Meaning responses like 500, 404, 400. So anything that we can control on a server and return as an actual status and appropriate code. Examples:
    1. { status: 500, code: DB_ERROR, message: "DB chocked..." }
    2. { status: 500, code: INTERNAL_SERVER_ERROR, message: "Server failed..." }
    3. { status: 400, code: INVALID_FILE, message: "Provided file is not..." }
  2. Uncontrolled errors. Meaning all the different things that may go wrong which we don't really control and we should catch. Examples:
    1. Network issues
    2. CORS
    3. Timeouts

There is a spec describing what is expected from error API response: RFC9457

The Problem

When using fetch() + RPC (tRPC, oRPC, Hono RPC etc), you know exactly what types of responses may come back. Both 2xx-status, as well as non-2xx (error responses). Because these are returned & either explicitly typed or inferred from your API endpoints.

However, what do you do in case of non-2xx response from fetch()? Do you throw new Error()?
If you do throw new Error() or CustomError() - you immediately lose the types of your non-2xx response.
So now you have to define return types on the wrapper function itself, and usually you don't want to do that, as it denies the whole purpose of RPC.

The problem is actually a bit deeper than inferring your errors from an API.

Problem 1. TypeScript error types

TypeScript can't infer "throw types". See this comment

Example with default Error:

function hello() {
    if (Math.random() > 0.5) {
        throw new Error("hey" as const)
    }
    return "worked"
}

async function test() {
    const res = hello()
    //     ^? const res: string
}
Enter fullscreen mode Exit fullscreen mode

Example with custom ValidationError:

class ValidationError extends Error {
  constructor(message: string|undefined) {
    super(message); // (1)
    this.name = "ValidationError"; // (2)
  }
}

function helloCustom() {
    if (Math.random() > 0.5) {
        throw new ValidationError("hey" as const)
    }
    return "worked"
}

async function testCustom() {
    const res = hello()
    //     ^? const res: string
}
Enter fullscreen mode Exit fullscreen mode

Problem 2. Tanstack Query error types

Tanstack Query also isn't designed for typed errors.

Let's assume you're using some RPC solution (e.g. tRPC, Hono RPC, oRPC) and Tanstack Query for your backend< >frontend type-safety.

To get an error from useQuery(), you have to throw from the queryFn().
And this is the type that useQuery() returns as const error: Error | null

By default useQuery() only checks whether functions throw, and all it's retry, onError, onSuccess logic is based on throw.

Example:

const {data, isLoading, isError, error} = useQuery({
    queryKey: ["todos"],
    queryFn: async () => {
        const response = await client.todos.$get(); // Hono RPC call
        if (!response.ok) { // check for non-2xx status; Can also be: if (response.status >= 500)
            const error = await response.json();
            throw new Error(error) // this makes Tanstack Query retry
        }

        const { data } = await response.json();
        return data;
    }
})
Enter fullscreen mode Exit fullscreen mode

The example above makes Tanstack Query automatically retry 3 times if !response.ok (non-2xx status). This default behavior can be modified.

isError and error come out of the box from useQuery() and can be used to display helpful reason to the user why things went wrong:

// const {data, isLoading, isError, error} = useQuery() ...

{isError && <div>There was an error: {error}</div>}
Enter fullscreen mode Exit fullscreen mode

The problem is that we don't know the exact type of the error that comes from useQuery. Is it controlled or uncontrolled error? The returned type is just generic: const error: Error | null.

RPC


Since we're using RPC (in this case - Hono RPC), it does give us the exact error types and objects expected from the backend. All the types are propagated (regardless if you used DTOs or not) from DB to the frontend. We just need to properly use them in our frontend.

const response = await client.todos.$post(); 
//  ^? response contains types both for successful and error responses

// For example:
const response: ClientResponse<{  
    error: {  
        readonly code: "INVALID_TITLE";  
        readonly message: "Title must be something that our CEO likes";  
        readonly status: 400; // returning status as object key is redundant, but some might prefer it
    };  
    }, 400, "json"> 
    | 
    ClientResponse<{  
        message: "Todo added successfully";  
        data: {  
            id: string;  
            title: string; 
        };  
    }, 201, "json"> 
    |
    ClientResponse<{  
        error: {  
            readonly code: "DATABASE_ERROR";  
            readonly message: "Database went asleep";  
            readonly status: 500;  
            details: string; // notice additional property
        };  
    }, 400, "json">
Enter fullscreen mode Exit fullscreen mode

The Solution: { data, error }

If we look at other languages like Rust, we can see that language mandates usage of Result<Ok, Err> as response from any function. No throw. This results in a very sane and explicit handling of the errors. Because you know exactly what type they are.

This is actually what Dillon Mulroy recommends at the end of his talk.

So, let's use the approach:

// **data** now always returns {data, error}
const {data, isLoading, isError, error} = useQuery({
    queryKey: ["todos"],
    queryFn: async () => {
        const response = await client.todos.$get(); // Hono RPC call
        if (!response.ok) {
          const { error } = await response.json();
          return { data: null, error }; // can also be: [null, error] as const
        }
        const { data } = await response.json();
        return { data, error: null }; // can also be: [data, null] as const
    }
})
// you can of course refactor this {data, error} wrapping into a separate helper function
Enter fullscreen mode Exit fullscreen mode

Now we can be sure that in const {data, isLoading, isError, error} = useQuery:

  • data is always of type { data, error }
  • error is only there if things really wen't wrong (uncontrolled errors)

Now in your code you have a benefit of tackling these errors separately:

// controlled (business) error - can present nice UI to the user to explain the issue
if (data?.error) {
    if (data.error.code === "TOO_MANY_TODOS_FOR_USER") alert("chill!")
    // ...
    // or use switch/case syntax
}

// uncontrolled error
if (isError) {
    return <div>Something went wrong when connecting to the server. Please refresh the page or contact support...</div>
}
Enter fullscreen mode Exit fullscreen mode

Vanilla solution

If you're not using Tanstack Query, it still holds:

async function getTodos() {
    const response = await client.todos.$get(); // Hono RPC call
    if (!response.ok) {
      const { error } = await response.json();
      return { data: null, error };
    }
    const { data } = await response.json();
    return { data, error: null };
}

try {
    const {data, error} = await getTodos()
} catch {
    // uncontrolled error
}
Enter fullscreen mode Exit fullscreen mode

Disadvantages

  • RPC approach kind of encourages to ditch DTO.
    • BUT, if you write your DTOs, then RPC will make it significantly easier.
  • It changes mental expectations of many developers.
    • Shall be imposed as "best practice" in teams.

Alternatives

  • neverthrow
    • Works fine until you want to cross the boundary of HTTP API request, where data needs to be serialized.
  • Effect.ts. Separate world. Don't even want to look at it at this point. You either go all-in, or don't do it at all.

Final words

Hope this rumble was useful to somebody. I'm open to hear better solutions that don't require to learn completely new programming language)

Top comments (0)