Best Practices ​
Expected vs. Unexpected Errors ​
When you're building applications, not all errors are created equal.
Expected Errors ​
Expected errors are the ones you anticipate during normal operation of your application. Think of them as part of your business logic — they're scenarios you've planned for and need to handle gracefully. These errors are perfect candidates for Result types because they represent valid outcomes that your users or calling code should know how to handle.
Common examples include:
- User input validation failures
- Business rule violations (like trying to withdraw more money than available)
- Resource not found scenarios
- Authentication and authorization failures
Unexpected Errors ​
Unexpected errors are the ones that shouldn't happen during normal operation — they usually indicate bugs, system failures, or truly exceptional circumstances. These are better handled with traditional exception throwing because they represent scenarios where your application can't continue normally.
Examples include:
- Network connectivity issues
- Database connection failures
- Out of memory errors
- Programming bugs
A Practical Example ​
Let's look at a simple e-commerce scenario to see this in action:
import { err, ok } from '@uni-ts/result';
async function purchaseProduct(productId: string, quantity: number) {
// Check if product exists (expected failure)
const product = await findProduct(productId);
if (!product) {
return err({
type: 'product_not_found',
productId,
});
}
// Check stock availability (expected failure)
if (product.stock < quantity) {
return err({
type: 'insufficient_stock',
requested: quantity,
available: product.stock,
});
}
// There are no expected errors for this database operation.
// We should let it throw and get caught by our error handling
// infrastructure placed on another application layer
const orderId = await saveOrder(productId, quantity);
return ok({ orderId });
}
In this example:
- Expected errors (product not found, insufficient stock) are wrapped in Result types because they're part of normal business logic that the calling code should handle.
- Unexpected errors (database failures) are allowed to throw because they indicate system problems that require different handling (logging, alerting, etc.).
This approach gives you the best of both worlds: explicit, type-safe handling of predictable failures, while still allowing truly exceptional circumstances to bubble up through your error handling infrastructure.
Approaches to Create Errors ​
When working with Result types, you have several options for representing your error values. Each approach has its own trade-offs in terms of simplicity, type safety, and extensibility. Let's explore three common patterns:
1. String-Based Errors ​
The simplest approach uses string literals to identify different error types. This is great for quick prototypes and simple applications where you don't need much error context.
import { err, isErr, ok } from '@uni-ts/result';
function validateEmail(email: string) {
if (email.length > 254) return err('too_long');
if (!email.includes('@')) return err('invalid_format');
return ok(email);
}
const result = validateEmail('not-an-email');
if (isErr(result)) {
if (result.error === 'invalid_format') {
console.log('Email must contain @ symbol');
}
if (result.error === 'too_long') {
console.log('Email is too long');
}
}
Pros:
- âś… Simple and lightweight
- âś… Easy to understand and use
- âś… Great TypeScript autocompletion
Cons:
- ❌ No additional context or metadata
- ❌ Can become unwieldy with many error types
- ❌ No extensibility
2. Plain Object Errors ​
Plain objects strike a balance between simplicity and expressiveness. You can include additional data while keeping the structure flat and easy to work with.
import { err, isErr, ok } from '@uni-ts/result';
function processPayment(userId: string, amount: number) {
if (userId.length !== 26) {
return err({ type: 'invalid_user_id' });
}
if (amount <= 0) {
return err({
type: 'invalid_amount',
reason: 'negative',
value: amount,
});
}
const user = getUser(userId);
if (user.balance < amount) {
return err({
type: 'business_error',
code: 'insufficient_funds',
userId,
});
}
const transactionId = createTransaction(userId, amount);
return ok({ transactionId });
}
const result = processPayment('user-123', -10);
if (isErr(result)) {
const error = result.error;
if (error.type === 'invalid_user_id') {
console.log('Invalid user ID');
}
if (error.type === 'invalid_amount') {
console.log(`Invalid amount: ${error.value}. Reason: ${error.reason}`);
}
if (error.type === 'business_error') {
console.log(`Rule violated: ${error.code} for user ${error.userId}`);
}
}
Pros:
- âś… Can include rich contextual data
- âś… Still lightweight and serializable
- âś… Great for API responses and logging
- âś… TypeScript discriminated unions work perfectly
Cons:
- ❌ No methods or behavior
- ❌ Can't leverage JavaScript's Error infrastructure like stack traces
3. Custom Error Classes ​
For more complex applications, custom error classes extending the native Error provide the most flexibility and integrate well with existing JavaScript error handling patterns.
import { err, isErr, ok } from '@uni-ts/result';
class ValidationError extends Error {
readonly type = 'validation_error';
constructor(
public field: string,
public reason: string,
public value: unknown,
) {
super(`Validation failed for ${field}: ${reason}`);
this.name = 'ValidationError';
}
}
class InsufficientFundsError extends Error {
readonly type = 'insufficient_funds';
constructor(
public userId: string,
public requested: number,
public available: number,
) {
super(`Insufficient funds: requested ${requested}, available ${available}`);
this.name = 'InsufficientFundsError';
}
}
function transferMoney(fromId: string, toId: string, amount: number) {
if (amount <= 0) {
return err(new ValidationError('amount', 'must be positive', amount));
}
const fromUser = getUser(fromId);
if (fromUser.balance < amount) {
return err(new InsufficientFundsError(fromId, amount, fromUser.balance));
}
const transferId = executeTransfer(fromId, toId, amount);
return ok({ transferId });
}
const result = transferMoney('user-1', 'user-2', 1000);
if (isErr(result)) {
const error = result.error;
console.log(error.message); // Human-readable message
console.log(error.stack); // Stack trace for debugging
if (error instanceof ValidationError) {
console.log(`Invalid ${error.field}: ${error.value}`);
}
if (error instanceof InsufficientFundsError) {
console.log(
`User ${error.userId} needs ${error.requested - error.available} more`,
);
}
}
Pros:
- âś… Better code organization (classes like
ValidationError
can be reused) - âś… Utilize native JavaScript Error infrastructure (stack traces, instanceof checks)
- âś… Integrates well with existing error handling patterns
- âś… Can include methods and computed properties
Cons:
- ❌ More verbose to create
- ❌ Requires more standardization upfront
- ❌ Needs to be serialized if you want to send it over the network (e.g. as API response)
đź’ˇ TypeScript Tip
Notice the readonly type
property in the custom error classes? This discriminant property helps TypeScript distinguish between different error types. Without it, TypeScript might unify similar-looking error classes into a single type, losing important type information.
Which Approach to Choose? ​
- Use strings for simple use cases with where error context can be contained within the string.
- Use plain objects when you need rich error data but want to keep things lightweight or plan to send it over the network.
- Use custom classes for complex applications where you need the full power of JavaScript's built-in Error system.