- 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:
- 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:
{ status: 500, code: DB_ERROR, message: "DB chocked..." }
{ status: 500, code: INTERNAL_SERVER_ERROR, message: "Server failed..." }
{ status: 400, code: INVALID_FILE, message: "Provided file is not..." }
- Uncontrolled errors. Meaning all the different things that may go wrong which we don’t really control and we should catch. Examples:
- Network issues
- CORS
- 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
}
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
}
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;
}
})
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>}
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">
The Solution: { data, error }
If we look at other languages like Rust, we can see that language mandates usage of Result
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
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>
}
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
}
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.
Hope this rumble was useful to somebody. I’m open to hear better solutions that don’t require to learn completely new programming language)