Skip to content

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:

typescript
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.

typescript
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.

typescript
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.

typescript
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.