Overview
better-result provides robust error handling through:
- TaggedError - Factory for creating discriminated error classes
- Exhaustive matching - Type-safe error handling with
matchError()
- UnhandledException - Wrapper for unexpected exceptions
- Panic - Unrecoverable errors (defects in user code)
TaggedError Factory
Creating Tagged Errors
TaggedError is a factory function that creates error classes with a _tag discriminator for exhaustive type checking.
class NotFoundError extends TaggedError("NotFoundError")<{
id: string;
message: string;
}>() {}
class ValidationError extends TaggedError("ValidationError")<{
field: string;
message: string;
}>() {}
const error = new NotFoundError({
id: "user-123",
message: "User not found"
});
console.log(error._tag); // "NotFoundError"
console.log(error.name); // "NotFoundError"
console.log(error.message); // "User not found"
console.log(error.id); // "user-123"
Why TaggedError?
TaggedError provides several benefits over plain Error objects:
The _tag property enables exhaustive pattern matching:type AppError = NotFoundError | ValidationError;
const handleError = (error: AppError) => {
matchError(error, {
NotFoundError: (e) => `Missing: ${e.id}`,
ValidationError: (e) => `Invalid: ${e.field}`,
});
};
TypeScript enforces that all error variants are handled.
Store domain-specific information:class ApiError extends TaggedError("ApiError")<{
status: number;
endpoint: string;
message: string;
retryable: boolean;
}>() {}
const error = new ApiError({
status: 429,
endpoint: "/api/users",
message: "Rate limit exceeded",
retryable: true,
});
TaggedError automatically chains Error causes in stack traces:class WrapperError extends TaggedError("WrapperError")<{
message: string;
cause: unknown;
}>() {}
const inner = new Error("root cause");
const outer = new WrapperError({
message: "wrapper",
cause: inner
});
console.log(outer.stack);
// WrapperError: wrapper
// at ...
// Caused by:
// Error: root cause
// at ...
TaggedError instances serialize cleanly:const error = new NotFoundError({
id: "123",
message: "Not found"
});
JSON.stringify(error.toJSON());
// {
// "_tag": "NotFoundError",
// "id": "123",
// "message": "Not found",
// "name": "NotFoundError",
// "stack": "..."
// }
Type Signature
function TaggedError<Tag extends string>(
tag: Tag
): <Props extends Record<string, unknown> = {}>() =>
TaggedErrorClass<Tag, Props>;
type TaggedErrorInstance<Tag extends string, Props> = Error & {
readonly _tag: Tag;
toJSON(): object;
} & Readonly<Props>;
type TaggedErrorClass<Tag extends string, Props> = {
new (
...args: keyof Props extends never ? [args?: {}] : [args: Props]
): TaggedErrorInstance<Tag, Props>;
is(value: unknown): value is TaggedErrorInstance<Tag, Props>;
};
Exhaustive Error Matching
matchError()
Exhaustively pattern match on a union of tagged errors. TypeScript enforces that all variants are handled.
class NotFoundError extends TaggedError("NotFoundError")<{
id: string;
message: string;
}>() {}
class ValidationError extends TaggedError("ValidationError")<{
field: string;
message: string;
}>() {}
class NetworkError extends TaggedError("NetworkError")<{
url: string;
message: string;
}>() {}
type AppError = NotFoundError | ValidationError | NetworkError;
const formatError = (error: AppError): string => {
return matchError(error, {
NotFoundError: (e) => `Missing: ${e.id}`,
ValidationError: (e) => `Invalid field '${e.field}': ${e.message}`,
NetworkError: (e) => `Network error at ${e.url}`,
});
};
If you remove a handler, TypeScript will produce a compile error. This ensures all error cases are handled.
Data-Last (Pipeable) API
const formatError = matchError<AppError, string>({
NotFoundError: (e) => `Missing: ${e.id}`,
ValidationError: (e) => `Invalid: ${e.field}`,
NetworkError: (e) => `Network error at ${e.url}`,
});
const result = formatError(error);
matchErrorPartial()
Partial pattern match with a fallback for unhandled errors.
const formatError = (error: AppError): string => {
return matchErrorPartial(
error,
{
NotFoundError: (e) => `Missing: ${e.id}`,
},
(e) => {
// e is ValidationError | NetworkError (NotFoundError excluded)
return `Other error: ${e._tag}`;
}
);
};
Type Narrowing in Fallback
The fallback parameter is typed as Exclude<E, HandledErrors>, providing type safety:
const handle = (error: AppError) => {
return matchErrorPartial(
error,
{
NotFoundError: (e) => `not found: ${e.id}`,
NetworkError: (e) => `network: ${e.url}`,
},
(e) => {
// e is ValidationError only (other two excluded)
const check: ValidationError = e;
return `validation: ${check.field}`;
}
);
};
Error Recovery Patterns
Mapping Errors to Default Values
const getUser = (id: string): Result<User, NotFoundError> => {
// ...
};
const getUserOrGuest = (id: string): User => {
return getUser(id).unwrapOr(GUEST_USER);
};
Converting Errors to Success
class NotFoundError extends TaggedError("NotFoundError")<{
id: string;
message: string;
}>() {}
const getOptionalUser = (
id: string
): Result<User | null, OtherError> => {
return getUser(id)
.match({
ok: (user) => Result.ok(user),
err: (e) => {
if (NotFoundError.is(e)) {
return Result.ok(null);
}
return Result.err(e);
},
});
};
Retrying on Specific Errors
class RateLimitError extends TaggedError("RateLimitError")<{
retryAfter: number;
message: string;
}>() {}
class FatalError extends TaggedError("FatalError")<{
message: string;
}>() {}
type ApiError = RateLimitError | FatalError;
const callApi = async (url: string) => {
return Result.tryPromise(
{
try: async () => {
const res = await fetch(url);
if (res.status === 429) {
throw new RateLimitError({
retryAfter: 1000,
message: "Rate limited",
});
}
if (!res.ok) {
throw new FatalError({ message: `HTTP ${res.status}` });
}
return res.json();
},
catch: (e) => e as ApiError,
},
{
retry: {
times: 3,
delayMs: 1000,
backoff: "constant",
shouldRetry: (e) => e._tag === "RateLimitError",
},
}
);
};
UnhandledException
UnhandledException wraps exceptions caught by Result.try() and Result.tryPromise() when no custom catch handler is provided.
const result = Result.try(() => JSON.parse("invalid"));
// Result<unknown, UnhandledException>
if (Result.isError(result)) {
console.log(result.error._tag); // "UnhandledException"
console.log(result.error.message); // "Unhandled exception: ..."
console.log(result.error.cause); // SyntaxError
}
When to Use UnhandledException
Use UnhandledException
Use Custom Errors
When you want quick error wrapping without defining custom error types:const parseJSON = (str: string): Result<unknown, UnhandledException> => {
return Result.try(() => JSON.parse(str));
};
For production code with domain-specific error handling:class ParseError extends TaggedError("ParseError")<{
input: string;
message: string;
cause: unknown;
}>() {}
const parseJSON = (str: string): Result<unknown, ParseError> => {
return Result.try({
try: () => JSON.parse(str),
catch: (cause) => new ParseError({
input: str.slice(0, 100),
message: "Failed to parse JSON",
cause,
}),
});
};
Panic: Unrecoverable Errors
Panic represents defects in user code - situations where recovery is impossible:
- Callback throws inside
map(), andThen(), match(), etc.
catch handler throws in Result.try() or Result.tryPromise()
finally block throws in Result.gen()
- Generator body throws before yielding
Symbol.dispose or Symbol.asyncDispose throws
Why Panic?
Unlike recoverable errors (wrapped in Err), Panics indicate programming errors that shouldn’t be caught and handled - they should be fixed.
// This is a defect - map callback should never throw
try {
Result.ok(1).map(() => {
throw new Error("oops");
});
} catch (e) {
console.log(e instanceof Panic); // true
console.log(e.message); // "map callback threw"
console.log(e.cause); // Error("oops")
}
Panic in Generators
try {
Result.gen(function* () {
try {
yield* Result.err("expected error");
return Result.ok(1);
} finally {
// Cleanup throws - this is a Panic
throw new Error("cleanup failed");
}
});
} catch (e) {
console.log(e instanceof Panic); // true
console.log(e.message); // "generator cleanup threw"
}
Panics should never be caught and ignored. They indicate bugs in your code that need to be fixed.
Avoiding Panics
// Bad: throws Panic
Result.ok(x).map(value => {
if (invalid(value)) throw new Error("bad");
return transform(value);
});
// Good: return Result from andThen
Result.ok(x).andThen(value => {
if (invalid(value)) return Result.err(new ValidationError({ ... }));
return Result.ok(transform(value));
});
Type Guards
TaggedError.is()
Every TaggedError class has a static is() type guard:
class NotFoundError extends TaggedError("NotFoundError")<{
id: string;
message: string;
}>() {}
const error: unknown = getError();
if (NotFoundError.is(error)) {
// error is NotFoundError
console.log(error.id);
console.log(error._tag); // "NotFoundError"
}
isTaggedError()
Check if a value is any TaggedError instance:
import { isTaggedError } from "better-result";
if (isTaggedError(error)) {
// error is Error & { _tag: string }
console.log(error._tag);
}
isPanic()
Check if a value is a Panic:
import { isPanic } from "better-result";
try {
riskyOperation();
} catch (e) {
if (isPanic(e)) {
// Defect in user code - log and crash
logger.fatal("Panic occurred", { error: e });
process.exit(1);
}
}
Best Practices
Use TaggedError for domain errors
Create discriminated error unions for your domain:class NotFoundError extends TaggedError("NotFoundError")<{
resource: string;
id: string;
message: string;
}>() {}
class PermissionError extends TaggedError("PermissionError")<{
userId: string;
action: string;
message: string;
}>() {}
type DomainError = NotFoundError | PermissionError;
Prefer matchError() over if-else chains:// Good: exhaustive, type-safe
matchError(error, {
NotFoundError: (e) => handle404(e),
PermissionError: (e) => handle403(e),
});
// Avoid: easy to miss cases
if (error._tag === "NotFoundError") {
// ...
} else if (error._tag === "PermissionError") {
// ...
}
Let Panics crash the application - they indicate bugs:// Bad: hiding defects
try {
Result.ok(x).map(buggyTransform);
} catch (e) {
return Result.err(e); // Don't do this!
}
// Good: fix the bug in buggyTransform
Result.ok(x).map(fixedTransform);
Include context in errors
Store enough information to debug issues:class ApiError extends TaggedError("ApiError")<{
endpoint: string;
method: string;
status: number;
body: unknown;
message: string;
cause: unknown;
}>() {}
Next Steps