Result Objects in TS
When working with async code, error handling is essential. In TypeScript we typically use try/catch blocks or .catch() with promises.
try {
await doSomething();
} catch (error) {
// do something with the error
}
This works fine for simple cases, but as we start doing more async operations, especially in parallel, it becomes complex. Consider a function that loads data for different parts of a page:
async function loader() {
try {
let [user, posts, notifications] = await Promise.all([
getUser(1),
getPosts(1),
getNotifications(1),
]);
return { user, posts, notifications };
} catch (error) {
// Which one failed? Can we still show the parts that succeeded?
return new Response("Something went wrong", { status: 500 });
}
}
If any of these fail, we lose everything. But what if we want to show the user's profile even if notifications failed to load?
Enter Result objects.
What is a Result?
A Result is a type that can be either a Success or a Failure. Instead of returning a value or throwing an error, functions return a Result object that explicitly represents both possibilities.
This way, if something throws, you know it's a true exception, something unexpected. But for errors you anticipate (user not found, validation failed, network error), you get a Failure Result.
The Types
Let's define what Success and Failure look like:
interface Success<T> {
status: "success";
data: T;
}
interface Failure<E extends Error> {
status: "failure";
error: E;
}
type Result<T, E extends Error> = Success<T> | Failure<E>;
A Success holds your data. A Failure holds an Error. The status property acts as a discriminant, letting TypeScript narrow the type when you check it.
Creating Results
Writing { status: "success", data: value } everywhere is verbose. Let's create helper functions:
function success<T>(data: T): Success<T> {
return { status: "success", data };
}
function failure<E extends Error>(error: E): Failure<E> {
return { status: "failure", error };
}
Now we can write functions that return Results:
async function getUser(id: number): Promise<Result<User, Error>> {
try {
let user = await db.users.find(id);
if (!user) return failure(new Error("User not found"));
return success(user);
} catch (error) {
return failure(error as Error);
}
}
Type Guards
To work with Results, we need to check their status. We could do result.status === "failure", but type guard functions are cleaner:
function isSuccess<T, E extends Error>(result: Result<T, E>): result is Success<T> {
return result.status === "success";
}
function isFailure<T, E extends Error>(result: Result<T, E>): result is Failure<E> {
return result.status === "failure";
}
Now we can handle Results with early returns, keeping the happy path unindented:
async function loader() {
let user = await getUser(1);
if (isFailure(user)) {
return new Response(user.error.message, { status: 500 });
}
// TypeScript knows user is Success<User> here
return new Response(JSON.stringify(user.data));
}
This is already better than try/catch, but we can go further. What if we need to parse JSON from an API response? Or retry failed requests? Let's build the tools we need.
Converting Throwing Code with wrap
Most existing code throws errors. We can convert it to use Results without rewriting everything:
function wrap<T>(fn: () => T): Result<T, Error> {
try {
return success(fn());
} catch (error) {
if (error instanceof Error) return failure(error);
return failure(new Error(String(error)));
}
}
Now we can safely work with code that throws:
async function fetchUserProfile(id: number): Promise<Result<User, Error>> {
// API call might fail
let response = await wrap(() => fetch(`/api/users/${id}`));
if (isFailure(response)) return response;
// JSON parsing might fail
let json = await wrap(() => response.data.json());
if (isFailure(json)) return json;
return success(json.data);
}
Transform Results with match
Often we need to transform a Result into something else. Instead of if/else, we can use pattern matching:
function match<T, E extends Error, R>(
result: Result<T, E>,
handlers: { success: (data: T) => R; failure: (error: E) => R },
): R {
if (isSuccess(result)) return handlers.success(result.data);
return handlers.failure(result.error);
}
This lets us transform Results in a single expression:
async function loader() {
let userResult = await getUser(1);
return match(userResult, {
success: (user) => new Response(JSON.stringify(user)),
failure: (error) => new Response(error.message, { status: 500 }),
});
}
Or transform to different types:
let statusCode = match(userResult, {
success: () => 200,
failure: (error) => (error.message.includes("not found") ? 404 : 500),
});
Extract Values with unwrap
Sometimes you want the value and are okay with throwing on failure, or you have a fallback:
function unwrap<T, E extends Error>(result: Result<T, E>, fallback?: (error: E) => T): T {
if (isSuccess(result)) return result.data;
if (fallback) return fallback(result.error);
throw result.error;
}
This is useful when you have sensible defaults:
// Throws if no user found
let user = unwrap(await getUser(1));
// Returns empty array if posts fail to load
let posts = unwrap(await getPosts(1), () => []);
// Returns error message as fallback
let message = unwrap(parseResult, (error) => error.message);
Retry Failed Operations
Network requests are unreliable. Let's build retry logic that fits into our Result pattern:
class RetryError extends Error {
name = "RetryError";
constructor(attempts: number) {
super(`Failed after ${attempts} attempts`);
}
}
async function retry<T, E extends Error>(
fn: () => Promise<Result<T, E>>,
options: { times: number; delay: number },
): Promise<Result<T, E | RetryError>> {
let attempts = 0;
while (attempts < options.times) {
let result = await fn();
if (isSuccess(result)) return result;
attempts++;
if (attempts < options.times) {
await new Promise((r) => setTimeout(r, options.delay));
}
}
return failure(new RetryError(attempts));
}
Now we can make our API calls resilient:
async function fetchUserProfile(id: number): Promise<Result<User, Error>> {
return retry(
() =>
wrap(async () => {
let response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}),
{ times: 3, delay: 1000 },
);
}
Handle Multiple Results with partition
Our original problem was loading multiple pieces of data in parallel. The partition function splits an array of Results into successes and failures:
function partition<T, E extends Error>(results: Result<T, E>[]): [T[], E[]] {
let successes: T[] = [];
let failures: E[] = [];
for (let result of results) {
if (isSuccess(result)) successes.push(result.data);
else failures.push(result.error);
}
return [successes, failures];
}
This is the missing piece for handling parallel operations gracefully.
Bringing It All Together
Now we can solve our original problem. Let's build a robust page loader that fetches multiple pieces of data, retries on failure, and renders whatever succeeds:
// First, let's create resilient data fetchers
async function fetchUser(id: number): Promise<Result<User, Error>> {
return retry(
() =>
wrap(async () => {
let response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error("User not found");
return response.json();
}),
{ times: 3, delay: 500 },
);
}
async function fetchPosts(userId: number): Promise<Result<Post[], Error>> {
return retry(
() =>
wrap(async () => {
let response = await fetch(`/api/users/${userId}/posts`);
if (!response.ok) throw new Error("Posts not available");
return response.json();
}),
{ times: 2, delay: 1000 },
);
}
async function fetchNotifications(userId: number): Promise<Result<Notification[], Error>> {
return wrap(async () => {
let response = await fetch(`/api/users/${userId}/notifications`);
if (!response.ok) throw new Error("Notifications not available");
return response.json();
});
}
// Now our loader can handle partial failures gracefully
async function loader() {
let userId = 1;
// Run everything in parallel
let results = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchNotifications(userId),
]);
let [successes, errors] = partition(results);
// Log what failed, but don't break the page
if (errors.length > 0) {
console.warn(
`${errors.length} operations failed:`,
errors.map((e) => e.message),
);
}
// Return what we have, with each component able to handle its Result
return {
user: results[0],
posts: results[1],
notifications: results[2],
loadedCount: successes.length,
failedCount: errors.length,
};
}
In our UI, each component handles its own Result state:
function Dashboard({ user, posts, notifications }: LoaderData) {
return (
<div>
<UserProfile result={user} />
<PostsList result={posts} />
<NotificationBell result={notifications} />
</div>
);
}
function UserProfile({ result }: { result: Result<User, Error> }) {
return match(result, {
success: (user) => <div>Welcome, {user.name}!</div>,
failure: (error) => <div>Failed to load profile: {error.message}</div>,
});
}
function PostsList({ result }: { result: Result<Post[], Error> }) {
return match(result, {
success: (posts) => (
<div>
{posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
),
failure: () => <div>Posts temporarily unavailable</div>,
});
}
function NotificationBell({ result }: { result: Result<Notification[], Error> }) {
let count = unwrap(result, () => []).length;
return <div>Notifications ({count})</div>;
}
Assertion Functions for Testing
For tests, we often know what the result should be. Assertion functions help:
function succeeded<T, E extends Error>(
result: Result<T, E>,
message = "Expected success",
): asserts result is Success<T> {
if (isFailure(result)) throw new Error(message, { cause: result.error });
}
function failed<T, E extends Error>(
result: Result<T, E>,
message = "Expected failure",
): asserts result is Failure<E> {
if (isSuccess(result)) throw new Error(message, { cause: result.data });
}
Usage in tests:
test("retries failed requests", async () => {
let result = await fetchUser(999); // non-existent user
failed(result);
expect(result.error).toBeInstanceOf(RetryError);
});
test("loads user successfully", async () => {
let result = await fetchUser(1);
succeeded(result);
expect(result.data.name).toBe("Alice");
});
Custom Error Types
For better type safety, use custom error classes:
class NotFoundError extends Error {
name = "NotFoundError";
}
class ValidationError extends Error {
name = "ValidationError";
constructor(
public field: string,
message: string,
) {
super(message);
}
}
function getUser(id: number): Result<User, NotFoundError> {
let user = db.users.find(id);
if (!user) return failure(new NotFoundError("User not found"));
return success(user);
}
Now TypeScript knows exactly what errors are possible, and you can handle them specifically. Note that custom error classes make serialization harder, so consider converting to plain Error objects when sending to the client.
Serialization Considerations
In React Router, you can return Error objects from loaders and they'll be serialized automatically. However, custom error classes lose their identity on the client.
You can handle this by transforming before returning:
return {
user: match(userResult, {
success: (user) => ({ status: "success" as const, data: user }),
failure: (e) => ({ status: "failure" as const, error: e.message }),
}),
};
Or handle errors server-side and only send success data:
let user = await getUser(1);
if (isFailure(user)) {
throw new Response("User not found", { status: 404 });
}
return { user: user.data };
When to Use Results
Use Result for:
- Validation errors
- Business logic errors (user not found, insufficient permissions)
- API calls that might fail
- Operations that should continue even if some parts fail
- When you want explicit error handling in your types
Use try/catch for:
- Truly exceptional conditions (out of memory, programming errors)
- When integrating with throwing libraries you can't change
- When failures should stop all execution
The Result pattern makes failure explicit in your types. You can't accidentally forget to handle an error because TypeScript won't let you access the data without checking first. Combined with the helper functions we've built, it creates a robust foundation for handling the inevitable failures in real applications.
Do you like my content?
Your sponsorship helps me create more tutorials, articles, and open-source tools.