When we call APIs, it’s tempting to assume things will “just work.”
Sometimes errors are ignored entirely. Other times they’re caught… but then left unhandled. Both lead to brittle, unpredictable code.
Over time, JavaScript has evolved several ways to handle async data — each with its own quirks.
How we got here
Callbacks (the old days)
Before Promises, callbacks ruled. Errors were the first argument (at least as good practice) — forcing you to deal with them:
var onInviteSent = function (error, result) {
if (error) {
ErrorTracking.captureException(error);
return;
}
toast.success("You are now friend with " + result.name);
};
inviteFriend(onInviteSent);
You could cheat and ignore the error, but at least you had to make that choice explicitly.
Promise
Promises cleaned up callback hell, but made it easy to skip error handling entirely:
inviteFriend().then((result) => {
// no .catch() — oops
});
Best practice was to chain .catch()
:
inviteFriend()
.then((result) => {
toast.success("You are now friend with " + result.name);
})
.catch((error) => {
ErrorTracking.captureException(error);
});
Async/Await
Finally, async/await gave us synchronous-looking code — but reintroduced the try/catch ceremony:
let friend;
try {
friend = await inviteFriend();
} catch (error) {
ErrorTracking.captureException(error);
}
toast.success("You are now friend with " + friend.name);
With TypeScript, this is even more awkward: if you don’t initialize/type friend
, it becomes any
.
If you do, you often duplicate your fallback value.
Next.js and Server Actions
In Next.js App Router, Server Actions are like quick REST endpoints:
// server-action.ts
"use server";
export async function getAllPosts() {
return db.select("*").from("posts");
}
But… what if something fails? You end up wrapping every call in try/catch:
// client.tsx
try {
const result = await getAllPosts();
} catch {
// now what?
}
It works, but it’s noisy and repetitive — especially across many actions.
A better contract
Why not neverthrow? The neverthrow package is a much better solution for our problem, but unfortunately it cannot be used in our case (Next.js). Server Actions' result must be serializable by React and neverthrow returns class instances with methods, which aren’t JSON-safe.
The Serializable Result Type
The biggest win? No try/catch ceremony, predictable error shapes, and TypeScript narrowing that makes happy-path code clean without ignoring failures. We can use a plain TypeScript Discriminated Union.
export type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
As you can see, when result success is true, it can only hold another property called data
. On the other hand, if the result success is false, it can only hold a property called error
.
This gives us a very clear API to work with, let's see an example:
// server-action.ts
"use server";
export function getAllPosts() {
return new Promise((resolve) => {
const result = db.select("*").from("posts");
if (result) {
resolve({ success: true, data: result });
} else {
resolve({ success: false, error: "an error has occurred" });
}
});
}
First of all notice that we never reject the promise, but always resolve with either success or fail result. This is very important, on the client side, we can now:
// client.tsx
const { success, error, data } = await getAllPosts();
if (!success) {
// error is now typed
toast.error(error);
}
setPosts(data);
Benefits
- No repetitive try/catch in every client call
- Predictable error handling - no random thrown types
- Works with TypeScript narrowing for safer happy-path code
- JSON-serializable for Server Actions & Route Handlers
Recap
- Never throw from Server Actions - always return a Result
- Keep it JSON-friendly
- Handle both branches explicitly on the client