The problem with throwing errors
Let's say that we want to make a request to create a post.
The following piece of code may throw and Error and may not have been handled properly in the caller.
async function createPost(input: { post: Post }): Promise<unknown> {
const _post = Post.parse(input.post); // ❗️ May throw an error
const res = await fetch(`https://example.com/api/posts`, {
method: "POST",
body: JSON.stringify(_post),
headers: {
"Content-type": "application/json; charset=UTF-8",
},
}); // ❗️ May throw an error
if (!res.ok) {
throw new Error("Failed to create Post");
}
return await res.json(); // ❗️ May throw an error
}
Try/Catch isn't Type Safe at the caller:
// infra/.../index.ts
// This can be anything that runs at the top level (e.g. a controller, lambda handler, CLI, React server component, server action, etc.)
async function run() {
try {
await createPost({
post: {
userId: 1,
title: "Hello",
body: "World",
},
});
} catch (e) {
// ^ we don't know what type of error this is.
// We can't easily handle different error types.
console.error(e);
}
}
Result based solution
The result type solution is an idea borrowed from the functional programing world. The Success and Error cases need to be defined explicitly.
We want to treat Errors as outputs and ensure that we never throw errors.
Result Type Implementation
Here's what code using the result type may look like.
function validateNumber(input: number): Result<number, Error> {
const num = Math.random();
if (num > 0.5) return Result.Success(num);
return Result.Failure(new Error("Number too small"));
}
const res: Result<number, Error> = validateNumber(0.1);
if (res.isOk) {
console.log(res.value);
} else {
console.error(res.error);
/*
* We still get the stack trace for where the error object was created
*
* Error: Number too small
* at validateNumber ...
* at main
*/
}
TypeScript will force us to check result.isOk
before being able to access result.value
. This makes use of discriminated unions.
Implement the Result type yourself with 25 lines of code.
This piece of code has been running throughout my production codebase. Feel free to copy and paste.
This solution is highly inspired by other functional TypeScript libraries.
I find including such libraries not worth it for simply wanting better error handling, especially if you work in a team.
// /utils/result.ts
export type Failure<E> = {
isOk: false;
error: E;
};
function Failure<E>(error: E): Failure<E> {
return {
isOk: false,
error,
};
}
export type Success<T> = {
isOk: true;
value: T;
};
function Success<T>(value: T): Success<T> {
return {
isOk: true,
value,
};
}
export type Result<T, E = never> = Success<T> | Failure<E>;
export const Result = {
Failure,
Success,
};
The Result Object can be boiled down to the following structure.
Failure Object
{
isOk: false,
// E is the generic type of the error. Can be anything (Error, string, etc.
error: E;
}
Success Object
{
isOk: true,
value: T; // T is the generic type of the value
}
Full example with Result Type
Let's reimplement our createPost()
function.
With the Result based solution, the errors and self-documenting and type-safe.
import { Result } from "#utils/result";
import z from "zod";
export * as API from "./apiWrapper";
// Custom error classes allows us to keep stack trace
export class StatusError extends Error {
name = "StatusError" as const;
}
export class JsonError extends Error {
name = "JsonError" as const;
}
export class ParseError extends Error {
name = "ParseError" as const;
}
export class NetworkError extends Error {
name = "NetworkError" as const;
}
export class ValidationError extends Error {
name = "ValidationError" as const;
}
type Post = z.infer<typeof Post>;
const Post = z.object({
userId: z.number().min(1),
title: z.string().min(5),
body: z.string().min(10),
});
export async function createPost(input: {
post: Post;
}): Promise<
Result<
null,
| UserDoesNotExistError
| StatusError
| JsonError
| NetworkError
| ValidationError
| ParseError
>
> {
// This just remaps the zod schema error.
const parsedPost = Post.safeParse(input.post);
if (!parsedPost.success) {
return Result.Failure(
new ValidationError("Failed to validate Post input", {
cause: parsedPost.error,
})
);
}
const _post = parsedPost.data;
const userRes = await getUserFromId(_post.userId);
if (!userRes.isOk) {
// Notice how we can return this failure result directly because it is already a result type.
return userRes;
}
if (userRes.value === null) {
return Result.Failure(
// It's good to include contextual information such as userId for better error messages.
new UserDoesNotExistError(`UserId: ${_post.userId} does not exist`)
);
}
const res = await fetch(`https://example.com/api/posts`, {
method: "POST",
body: JSON.stringify(_post),
headers: {
"Content-type": "application/json; charset=UTF-8",
},
});
if (!res.ok) {
return Result.Failure(new StatusError("Failed to create Post"));
}
let json;
try {
json = await res.json();
} catch (e) {
return Result.Failure(
new JsonError("Failed to parse Post JSON", {
cause: e,
})
);
}
const parsedResponse = PostDTO.safeParse(json);
if (!parsedResponse.success) {
return Result.Failure(
new ParseError("Failed to validate Post response schema", {
cause: parsedResponse.error,
})
);
}
return Result.Success(null);
}
Pattern match your errors with switch case. Handle or throw them as you please.
💡 I generally just throw (Panic) for database errors since it indicates that something is horribly wrong. Only expected errors (Domain errors) will be passed to the failure path.
import { API } from "#apiWrapper";
// infra/.../index.ts
// This can be anything that runs at the top level (e.g. a controller, lambda handler, CLI, React server component, server action, etc.)
export default async function run(input: {
userId: number;
post: {
title: string;
body: string;
};
}): Promise<{
errorMessage?: string;
}> {
const res = await API.createPost({
post: {
userId: input.userId,
title: input.post.title,
body: input.post.body,
},
});
if (res.isOk) {
return {};
}
captureError(res.error);
// Handle different error types
switch (res.error.name) {
case "NetworkError":
// We want to crash on network errors
throw res.error;
// Return generic error message for other errors as we don't want to expose internal error details
case "UserDoesNotExistError":
return {
errorMessage: "User does not exist",
};
case "StatusError":
return {
errorMessage: "Failed to create post",
};
case "JsonError":
return {
errorMessage: "Failed to parse response",
};
case "ValidationError":
return {
errorMessage: "Invalid post input",
};
case "ParseError":
return {
errorMessage: "Failed to validate response",
};
}
}
// Can use telemetry to capture errors such as Sentry.io, OpenTelemetry, etc.
function captureError(e: unknown) {
console.error(e);
Sentry.captureException(e);
}
💡 Note: You can also just use a generic Error type Result<..., Error>. This example just illustrates that you can include different error types.
Serializable Result
Using primitives for Values/Errors allows the Result Types to be serializable.
function validateUser(): Result<{userId: number}, "UserValidationError">
...
return Result.Failure("UserValidationError");
}
💡 Note: With strings as errors, we lose the stack trace.
Arguments against Result Type
If one forgets to handle the returned result. The Error can be lost.
It is best to also return the result or map the result error to another return value to avoid this caveat.
Needing wrappers for functions that throw.
This is a good tradeoff for production applications.
Code may look unfamiliar.
The actual result code is only 25 lines. If your teammates still prefer using try/catch, then yes. It is better to avoid the Result pattern.
References
Two Types of Errors. (2024). Effect Documentation. https://effect.website/docs/error-management/two-error-types/
Wlaschin, S. (2018). Domain modeling made functional: Tackle software complexity with domain-driven design and F#. Pragmatic Bookshelf.
Top comments (0)