2024-12-03
The Try monad represents computations that might fail. Instead of using traditional try-catch blocks, which can lead to imperative and harder-to-compose code, Try provides a functional approach to error handling. Originally popularized in Scala, Try wraps a value that either contains a successful result or an error, allowing developers to chain operations and handle errors in a consistent way. Before we get into the details, here is a comparison of try/catch error handling with Try.
// Traditional approach
function getUserData(id: string) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('HTTP error');
const data = await response.json();
return processUserData(data);
} catch (error) {
logger.error(error);
return defaultUserData;
}
}
// Try-based approach
async function getUserData(id: string) {
return await asyncTry(fetch(`/api/users/${id}`))
.flatMap(response =>
response.ok
? asyncTry(response.json())
: Try.failure(new Error('HTTP error'))
)
.map(processUserData)
.unwrapOr(defaultUserData);
}
It’s considerably different. Let’s get into the details.
The core structure of Try is implemented as an abstract class with two concrete implementations: Success and Failure:
export abstract class Try<T> {
abstract readonly isSuccess: boolean;
abstract readonly isFailure: boolean;
static success<T>(value: T): Try<T> {
return new Success(value);
}
static failure<T>(error: Error): Try<T> {
return new Failure(error);
}
}
export class Success<T> extends Try<T> {
readonly isSuccess: boolean = true;
readonly isFailure: boolean = false;
constructor(readonly value: T) {
super();
}
}
export class Failure<T> extends Try<T> {
readonly isSuccess: boolean = false;
readonly isFailure: boolean = true;
constructor(readonly error: Error) {
super();
}
}
The abstract class defines the interface that both Success and
Failure must implement, while the static factory methods
success
and failure
ensure that developers
create Try instances in a consistent way. This design makes it
immediately clear whether you’re dealing with a success or failure case
when you see Try.success
or Try.failure
in the
code.
The generic type parameter T
allows Try to wrap any kind
of value while maintaining type safety throughout operations. The
Success class holds the actual value, while the Failure class contains
an Error instance.
The state inspection methods are now implemented as readonly properties and type guards:
// Properties on Try instances
readonly isSuccess: boolean;
readonly isFailure: boolean;
// Type guard functions
export function isSuccess<T>(tryValue: Try<T>): tryValue is Success<T> {
return tryValue.isSuccess;
}
export function isFailure<T>(tryValue: Try<T>): tryValue is Failure<T> {
return tryValue.isFailure;
}
These properties and type guards are crucial for making decisions about how to process values and provide TypeScript with type information:
const userProfile = await fetchUserProfile(userId);
if (isSuccess(userProfile)) {
// TypeScript knows userProfile is Success<Profile> here
renderProfile(userProfile.value);
} else {
// TypeScript knows userProfile is Failure<Profile> here
showErrorState();
metrics.incrementCounter('profile_load_failures');
}
State inspection often precedes value extraction or serves as a branching point in business logic. For example, in a data processing pipeline:
const processData = (input: string) => {
const result = parseData(input);
if (isFailure(result)) {
// Handle the error early
notifyAdmin('Data parsing failed');
return defaultResponse();
}
// Continue with processing
return transformData(result.unwrap());
};
The Value Extraction methods provide different ways to safely access the wrapped value in a Try instance:
abstract unwrap(): T;
abstract unwrapOr(defaultValue: T): T;
abstract ok(): Option<T>;
abstract match<U>(pattern: {
success: (value: T) => U;
failure: (error: Error) => U;
}): U;
unwrap
provides direct access to the success value but
will throw if called on a failure. Use it when you’re certain the Try
contains a success value, typically after checking with
isSuccess
:
const userAge = syncTry(() => getUserAge())
if (userAge.isSuccess) {
// Safe to use unwrap here
const age = userAge.unwrap();
console.log(`User is ${age} years old`);
}
unwrapOr
handles failure cases by providing a default
value. This method never throws, making it ideal for situations where
the computation should continue even if the original value is
unavailable:
// User settings with defaults
const settings = tryGetUserSettings(userId)
.unwrapOr({
theme: "light",
fontSize: 12,
language: "en"
});
// Continue using settings regardless of success/failure
applyUserSettings(settings);
match
provides a powerful way to handle both success and
failure cases in a single expression:
const result = tryGetUserData(userId).match({
success: (user) => `Welcome, ${user.name}!`,
failure: (error) => `Failed to load user: ${error.message}`
});
Pattern matching is particularly useful when you need to transform both success and failure cases into a common type:
interface ApiResponse {
status: 'success' | 'error';
data?: any;
error?: string;
}
const response = tryFetchData().match({
success: (data): ApiResponse => ({
status: 'success',
data
}),
failure: (error): ApiResponse => ({
status: 'error',
error: error.message
})
});
ok
creates a bridge between Try and Option types. While
Try represents a computation that might fail with an error, Option
represents a value that might not exist. This method transforms error
cases into absent values while preserving success cases:
const userPreferences = tryLoadPreferences().ok();
// userPreferences is now Option<Preferences>
// Instead of asking "did it fail?", we ask "is it present?"
if (userPreferences.isSome()) {
applyPreferences(userPreferences.unwrap());
} else {
useDefaultPreferences();
}
The shift from Try to Option changes how we think about the value. Try focuses on success/failure, while Option focuses on presence/absence. This distinction becomes important in domain modeling:
// Error-focused approach with Try
const tryGetUser = (id: string): Try<User> => {
if (invalidId(id)) {
return Try.failure(new Error("Invalid ID"));
}
return Try.success(loadUser(id));
};
// Presence-focused approach with Option
const findUser = (id: string): Option<User> => {
return tryGetUser(id).ok();
};
// Usage focuses on presence rather than errors
const user = findUser(id);
if (user.isSome()) {
welcomeUser(user.unwrap());
} else {
showSignUpPrompt();
}
The transformation methods enable complex operations while maintaining error handling context. Each method serves a specific purpose in data transformation pipelines:
abstract map<U>(fn: (value: T) => U): Try<U>;
abstract flatMap<U>(fn: (value: T) => Try<U>): Try<U>;
abstract recover(fn: (error: Error) => T): Try<T>;
map
transforms success values while maintaining the Try
context. It’s ideal for simple transformations that don’t involve error
handling themselves:
const userAge = parseUserData(rawData)
.map(user => user.age)
.map(age => age + 1)
.map(age => `Age next year: ${age}`);
If the mapping function throws an error, it will be caught and wrapped in a Failure:
const result = Try.success("123")
.map(x => {
throw new Error("Oops!");
return parseInt(x);
});
// result is Failure<number> containing the "Oops!" error
While map
transforms values directly:
Try<A> -> (A -> B) -> Try<B>
flatMap
handles nested transformations:
Try<A> -> (A -> Try<B>) -> Try<B>
flatMap
handles operations that themselves return Try
values. This prevents nested Try instances and maintains clean error
handling:
// With map (leads to Try<Try<User>>):
const result = Try.success(userId)
.map(id => fetchUser(id)); // fetchUser returns Try<User>
// With flatMap (gives Try<User>):
const result = Try.success(userId)
.flatMap(id => fetchUser(id));
recover
provides a way to handle errors by attempting to
produce a valid value:
const userSettings = loadUserSettings(userId)
.recover(error => {
logger.warn(`Failed to load settings: ${error.message}`);
return getDefaultSettings();
});
If the recovery function throws, the Try will contain the new error:
const result = Try.failure(new Error("First error"))
.recover(error => {
throw new Error("Recovery failed");
});
// result is Failure containing "Recovery failed" error
The utility functions provide convenient ways to create Try instances from both synchronous and asynchronous operations:
export function syncTry<T>(f: () => T): Try<T> {
try {
return Try.success(f());
} catch (e) {
return Try.failure(e instanceof Error ? e : new Error(String(e)));
}
}
export async function asyncTry<T>(promise: Promise<T>): Promise<Try<T>> {
try {
const result = await promise;
return Try.success(result);
} catch (e) {
return Try.failure(e instanceof Error ? e : new Error(String(e)));
}
}
Use syncTry
for operations that might throw errors:
const parsedData = syncTry(() => JSON.parse(rawData))
.map(data => processData(data))
.unwrapOr(defaultData);
asyncTry
wraps Promise-based operations, providing
consistent error handling for asynchronous code:
const userData = await asyncTry(fetch('/api/user'))
.flatMap(response =>
response.ok
? asyncTry(response.json())
: Try.failure(new Error('HTTP error'))
)
.match({
success: data => ({ status: 'success', data }),
failure: error => ({
status: 'error',
message: error.message
})
});
Try instances can be serialized to JSON and converted to strings:
const success = Try.success(42);
console.log(success.toString()); // "Try.success(42)"
console.log(JSON.stringify(success)); // {"type":"Try.success","value":42}
const failure = Try.failure(new Error("oops"));
console.log(failure.toString()); // "Try.failure(Error: oops)"
console.log(JSON.stringify(failure)); // {"type":"Try.failure","value":{}}
Try is particularly valuable when:
Avoid Try when:
Try makes testing easier by making error paths explicit and providing type guards for precise type checking:
describe('getUserData', () => {
it('handles successful responses', async () => {
const result = await getUserData('123');
expect(isSuccess(result)).toBe(true);
if (isSuccess(result)) {
expect(result.value).toEqual(expectedData);
}
});
it('handles network errors', async () => {
const result = await getUserData('invalid');
expect(isFailure(result)).toBe(true);
expect(result.unwrapOr(defaultData)).toEqual(defaultData);
});
it('transforms data correctly', async () => {
const result = await getUserData('123');
const formatted = result.match({
success: user => `User: ${user.name}`,
failure: error => `Error: ${error.message}`
});
expect(formatted).toEqual('User: John');
});
});
unwrap
without checking:// Bad
const value = try.unwrap(); // Might throw
// Good
if (isSuccess(try)) {
const value = try.unwrap();
}
// Better
const value = try.match({
success: value => value,
failure: error => defaultValue
});
// Bad
const nested = Try.success(Try.success(value));
// Good
const flat = Try.success(value)
.flatMap(v => processValue(v));
// Less clear
let result;
if (isSuccess(try)) {
result = processSuccess(try.value);
} else {
result = handleError(try.error);
}
// Clearer
const result = try.match({
success: value => processSuccess(value),
failure: error => handleError(error)
});
Let’s look at some real-world scenarios where Try shines:
interface Config {
port: number;
host: string;
timeout: number;
}
function loadConfig(path: string): Try<Config> {
return syncTry(() => fs.readFileSync(path, 'utf8'))
.flatMap(content => syncTry(() => JSON.parse(content)))
.flatMap(json => validateConfig(json))
.match({
success: config => Try.success(config),
failure: error => Try.success({
port: 3000,
host: 'localhost',
timeout: 5000
})
});
}
interface UserData {
id: string;
profile: Record<string, unknown>;
}
async function processUserData(userId: string) {
return await asyncTry(fetch(`/api/users/${userId}`))
.flatMap(response =>
response.ok
? asyncTry(response.json())
: Try.failure(new Error(`HTTP ${response.status}`))
)
.flatMap(data => validateUserData(data))
.map(enrichUserData)
.match({
success: (data: UserData) => ({
status: 'success',
data,
timestamp: new Date()
}),
failure: error => ({
status: 'error',
error: error.message,
timestamp: new Date()
})
});
}
interface FormData {
email: string;
age: number;
}
function validateForm(input: unknown): Try<FormData> {
return syncTry(() => {
if (typeof input !== 'object' || !input) {
throw new Error('Invalid input');
}
const { email, age } = input as Record<string, unknown>;
if (typeof email !== 'string' || !email.includes('@')) {
throw new Error('Invalid email');
}
if (typeof age !== 'number' || age < 0) {
throw new Error('Invalid age');
}
return { email, age };
});
}
const result = validateForm({ email: 'test@example.com', age: 25 })
.map(data => enrichFormData(data))
.match({
success: data => ({ valid: true, data }),
failure: error => ({ valid: false, error: error.message })
});
The Try monad transforms error handling from a necessary evil into a powerful tool for expressing business logic. It brings several key benefits:
First, it makes error handling explicit and impossible to ignore. Unlike promises that can swallow errors or try-catch blocks that can be forgotten, Try forces developers to make conscious decisions about error cases.
Second, it enables composition of operations that might fail. The
transformation methods (map
, flatMap
, and
recover
) create clean pipelines that handle errors
automatically, reducing boilerplate and improving code clarity.
Third, through pattern matching and the Option type bridge, Try provides flexibility in how errors are handled and transformed. Developers can choose whether to handle errors directly, convert them to optional values, or transform both success and failure cases into a common type.
For TypeScript developers, Try offers a path toward more maintainable codebases. It replaces scattered try-catch blocks with a consistent pattern that scales well as applications grow. When combined with other functional programming patterns, it forms part of a robust toolkit for handling complexity in modern applications.
The key to effective use of Try lies not just in understanding its
mechanics, but in recognizing when to use each of its tools. Whether you
need the strict error handling of unwrap
, the safe defaults
of unwrapOr
, or the expressive power of pattern matching,
Try provides the right tool for each situation.