Overview
Pattern matching in better-result allows you to handle both success and error cases in a type-safe way:
- Result.match() - Exhaustive matching with ok/err handlers
- isOk() / isErr() - Type guards for narrowing
- Data-first and data-last APIs - Flexible composition styles
Result.match()
Exhaustively handle both success and error cases by providing handlers for each variant.
Basic Usage
const result: Result<number, string> = getNumber();
const message = result.match({
ok: (value) => `Success: ${value}`,
err: (error) => `Error: ${error}`,
});
Type Signature
// Method style (data-first)
class Ok<A, E> {
match<T>(handlers: { ok: (a: A) => T; err: (e: never) => T }): T;
}
class Err<T, E> {
match<R>(handlers: { ok: (a: never) => R; err: (e: E) => R }): R;
}
// Function style (dual API)
function match<A, E, T>(
result: Result<A, E>,
handlers: { ok: (a: A) => T; err: (e: E) => T }
): T;
function match<A, E, T>(
handlers: { ok: (a: A) => T; err: (e: E) => T }
): (result: Result<A, E>) => T;
Real-World Example
class NotFoundError extends TaggedError("NotFoundError")<{
id: string;
message: string;
}>() {}
class NetworkError extends TaggedError("NetworkError")<{
url: string;
message: string;
}>() {}
type FetchError = NotFoundError | NetworkError;
const displayUser = (result: Result<User, FetchError>) => {
return result.match({
ok: (user) => {
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
},
err: (error) => {
// error is NotFoundError | NetworkError
return matchError(error, {
NotFoundError: (e) => <p>User {e.id} not found</p>,
NetworkError: (e) => <p>Network error: {e.url}</p>,
});
},
});
};
Data-First vs Data-Last
better-result supports both API styles for maximum flexibility.
Data-First (Method Style)
Call match() as a method on the Result instance:
const result = fetchUser("123");
const message = result.match({
ok: (user) => `Found: ${user.name}`,
err: (error) => `Error: ${error.message}`,
});
Data-first is more concise for one-off transformations.
Data-Last (Pipeable Style)
Pass handlers first, Result last - enables composition:
const formatResult = Result.match<User, FetchError, string>({
ok: (user) => `Found: ${user.name}`,
err: (error) => `Error: ${error.message}`,
});
const message1 = formatResult(fetchUser("123"));
const message2 = formatResult(fetchUser("456"));
Data-last is useful for creating reusable transformations and pipelines.
Pipeable Composition
Combine multiple transformations:
import { pipe } from "better-result";
const processUser = pipe(
fetchUser("123"),
Result.map(user => user.name),
Result.map(name => name.toUpperCase()),
Result.match({
ok: (name) => `User: ${name}`,
err: (error) => `Failed: ${error.message}`,
})
);
Type Narrowing with isOk/isErr
Use type guards to narrow the Result type before accessing properties.
isOk() Type Guard
const result: Result<User, NotFoundError> = fetchUser(id);
if (result.isOk()) {
// result is Ok<User, NotFoundError>
console.log(result.value.name);
console.log(result.status); // "ok"
} else {
// result is Err<User, NotFoundError>
console.log(result.error.id);
console.log(result.status); // "error"
}
isErr() Type Guard
const result: Result<User, NotFoundError> = fetchUser(id);
if (result.isErr()) {
// result is Err<User, NotFoundError>
console.log(result.error.message);
console.log(result.error._tag); // "NotFoundError"
return;
}
// result is Ok<User, NotFoundError>
console.log(result.value.name);
Negation Type Narrowing
Negated guards also narrow the type:
const result: Result<number, string> = compute();
if (!result.isOk()) {
// result is Err<number, string>
console.log(result.error);
return;
}
// result is Ok<number, string>
console.log(result.value * 2);
Static Type Guards
Use static functions from the Result namespace:
Result.isOk()
import { Result } from "better-result";
const result = fetchUser(id);
if (Result.isOk(result)) {
console.log(result.value.name);
}
Result.isError()
import { Result } from "better-result";
const result = fetchUser(id);
if (Result.isError(result)) {
console.log(result.error.message);
}
Result.isError() (with an “r”) matches the Err status field which is "error" (also with an “r”).
Exhaustive Matching Patterns
Converting to HTTP Response
type ApiError = NotFoundError | ValidationError | InternalError;
const toHttpResponse = (
result: Result<Data, ApiError>
): HttpResponse => {
return result.match({
ok: (data) => ({
status: 200,
body: JSON.stringify(data),
}),
err: (error) => matchError(error, {
NotFoundError: (e) => ({
status: 404,
body: JSON.stringify({ error: e.message }),
}),
ValidationError: (e) => ({
status: 400,
body: JSON.stringify({ field: e.field, error: e.message }),
}),
InternalError: (e) => ({
status: 500,
body: JSON.stringify({ error: "Internal server error" }),
}),
}),
});
};
Async Operations
const processUser = async (id: string) => {
const result = await fetchUser(id);
return result.match({
ok: async (user) => {
await sendWelcomeEmail(user.email);
await logUserActivity(user.id);
return `Processed: ${user.name}`;
},
err: async (error) => {
await logError(error);
return `Failed: ${error.message}`;
},
});
};
Early Return Pattern
const updateUser = (id: string, data: UserData): Result<User, AppError> => {
const userResult = fetchUser(id);
if (userResult.isErr()) {
return userResult; // Early return with error
}
const user = userResult.value;
const validationResult = validateUserData(data);
if (validationResult.isErr()) {
return validationResult; // Early return with validation error
}
return saveUser({ ...user, ...data });
};
The early return pattern works well for imperative code. For functional composition, use andThen() or Result.gen() instead.
Combining Pattern Matching
Nested Results
const processOrder = (
orderId: string
): Result<string, FetchError | ValidationError | PaymentError> => {
return fetchOrder(orderId).match({
ok: (order) => {
return validateOrder(order).match({
ok: (validOrder) => {
return processPayment(validOrder).match({
ok: (payment) => Result.ok(`Processed: ${payment.id}`),
err: (error) => Result.err(error),
});
},
err: (error) => Result.err(error),
});
},
err: (error) => Result.err(error),
});
};
Nested match() calls can become verbose. Consider using andThen() or Result.gen() for cleaner composition.
Flattened with andThen
Same logic, cleaner:
const processOrder = (
orderId: string
): Result<string, FetchError | ValidationError | PaymentError> => {
return fetchOrder(orderId)
.andThen(order => validateOrder(order))
.andThen(validOrder => processPayment(validOrder))
.map(payment => `Processed: ${payment.id}`);
};
Error Handler Patterns
Logging Errors
const logAndReturn = <T, E>(
result: Result<T, E>,
logger: Logger
): Result<T, E> => {
return result.match({
ok: (value) => {
logger.info("Operation succeeded", { value });
return Result.ok(value);
},
err: (error) => {
logger.error("Operation failed", { error });
return Result.err(error);
},
});
};
Converting Errors to Metrics
const trackResult = <T, E extends { _tag: string }>(
result: Result<T, E>,
metrics: Metrics
): Result<T, E> => {
return result.match({
ok: (value) => {
metrics.increment("operation.success");
return Result.ok(value);
},
err: (error) => {
metrics.increment(`operation.error.${error._tag}`);
return Result.err(error);
},
});
};
Fallback Values
const withFallback = <T>(
result: Result<T, unknown>,
fallback: T
): T => {
return result.match({
ok: (value) => value,
err: () => fallback,
});
};
// Or use unwrapOr:
const value = result.unwrapOr(fallback);
Type Inference
TypeScript infers return types from your handlers:
const result: Result<number, string> = getNumber();
// Inferred type: string
const message = result.match({
ok: (n) => `Got ${n}`,
err: (e) => `Error: ${e}`,
});
// Inferred type: number
const value = result.match({
ok: (n) => n * 2,
err: () => 0,
});
// Inferred type: { status: string; value?: number }
const obj = result.match({
ok: (n) => ({ status: "ok", value: n }),
err: () => ({ status: "error" }),
});
Best Practices
Use match() for branching logic
When you need different logic for success vs error:result.match({
ok: (user) => redirectToProfile(user),
err: (error) => showErrorPage(error),
});
Use isOk/isErr for early returns
When you want to handle errors first:if (result.isErr()) {
log.error(result.error);
return;
}
// Continue with result.value
Use unwrapOr for simple fallbacks
When you just need a default value:const count = getCount().unwrapOr(0);
Prefer andThen over nested match
For sequential operations:// Good
fetchUser(id)
.andThen(user => validateUser(user))
.andThen(user => saveUser(user));
// Avoid - too nested
fetchUser(id).match({
ok: (user) => validateUser(user).match({ ... }),
err: (e) => Result.err(e),
});
Real-World Example
Complete user registration flow:
class ValidationError extends TaggedError("ValidationError")<{
field: string;
message: string;
}>() {}
class DuplicateError extends TaggedError("DuplicateError")<{
email: string;
message: string;
}>() {}
class DatabaseError extends TaggedError("DatabaseError")<{
message: string;
cause: unknown;
}>() {}
type RegistrationError = ValidationError | DuplicateError | DatabaseError;
const registerUser = async (
input: RegistrationInput
): Promise<Result<User, RegistrationError>> => {
// Validate input
const validationResult = validateInput(input);
if (validationResult.isErr()) {
return validationResult;
}
// Check for duplicates
const existingUser = await findUserByEmail(input.email);
if (existingUser.isOk()) {
return Result.err(new DuplicateError({
email: input.email,
message: "Email already registered",
}));
}
// Create user
const createResult = await createUser(validationResult.value);
return createResult.match({
ok: (user) => {
logger.info("User registered", { userId: user.id });
metrics.increment("user.registered");
return Result.ok(user);
},
err: (error) => {
logger.error("Registration failed", { error });
metrics.increment(`user.registration.error.${error._tag}`);
return Result.err(error);
},
});
};
// Usage
const result = await registerUser(input);
const response = result.match({
ok: (user) => ({
status: 201,
body: { id: user.id, email: user.email },
}),
err: (error) => matchError(error, {
ValidationError: (e) => ({
status: 400,
body: { field: e.field, error: e.message },
}),
DuplicateError: (e) => ({
status: 409,
body: { error: "Email already exists" },
}),
DatabaseError: (e) => ({
status: 500,
body: { error: "Internal server error" },
}),
}),
});
Next Steps