Hidden Costs of Throwing Exceptions and the Result Pattern Solution
This article examines the hidden performance and maintenance drawbacks of using thrown exceptions for error handling in JavaScript/TypeScript, introduces the Result pattern as a type‑safe, predictable alternative, and demonstrates its implementation, testing, and appropriate use cases with practical code examples.
Many developers default to throwing exceptions for error handling, but this approach has significant drawbacks that can make codebases hard to maintain.
Hidden Risks of Throwing Exceptions
Performance Issues – Exceptions Are Slow : Throwing an exception incurs high computational cost because the JavaScript engine must create a stack trace and perform other heavy operations.
Exception throw: ~1000× slower than normal return
Stack trace creation: heavy CPU load
Result pattern: almost no performance loss
Creating stack trace
Back‑tracing call stack
Finding appropriate catch handler
Cleaning resources in finally block
Hidden Control Flow
class UserService {
async createUser(email: string): Promise<User> {
if (!this.isValidEmail(email)) {
throw new Error("Invalid email format");
}
if (await this.emailExists(email)) {
throw new Error("Email already exists");
}
return this.saveUser(new User(email));
}
}
try {
const user = await userService.createUser("invalid-email");
} catch (error) {
console.log(error.message);
}Implicit Error Handling : A method signature like Promise<User> does not convey possible failure cases, forcing callers to dig into documentation or implementation.
Inconsistent Error Handling : Different developers may throw different exception types (e.g., ValidationError , Error , or even strings), creating a chaotic error landscape.
Testing Nightmare : Every test for an error scenario must wrap calls in try/catch , leading to verbose and hard‑to‑maintain test code.
it('should handle invalid email', async () => {
try {
await userService.createUser('invalid-email');
fail('Should have thrown an error');
} catch (error) {
expect(error.message).toContain('Invalid email');
}
});Result Pattern: A Better Choice
The result pattern is a functional programming approach that makes error handling explicit , predictable and type‑safe . Instead of throwing, methods return a Result<T> object that contains either a success value or an error.
You can think of it as a labeled box that tells you what’s inside before you open it.
How the Result Pattern Works
The result pattern wraps an operation’s outcome in a container, clearly indicating:
✅ Success : contains the expected value
❌ Failure : contains an error message
This forces you to handle both cases explicitly, eliminating unexpected exceptions and making code more predictable.
Result Class Implementation
export class Result<T> {
private readonly success: boolean;
private readonly value?: T;
private readonly error?: Error;
private constructor(success: boolean, value?: T, error?: Error) {
this.success = success;
this.value = value;
this.error = error;
}
static success<T>(value?: T): Result<T> {
return new Result<T>(true, value);
}
static error<T>(error: Error): Result<T> {
return new Result<T>(false, undefined, error);
}
isSuccess(): boolean { return this.success; }
isFailure(): boolean { return !this.isSuccess(); }
getValue(): T | undefined { return this.value; }
getError(): Error | undefined { return this.error; }
}Why This Design Works
Immutable Design
const result = Result.success("Hello");
result.success = false; // Compile‑time errorType Safety
const userResult: Result<User> = await getUser(id);
if (userResult.isSuccess()) {
const user: User = userResult.getValue();
}Controlled Creation
new Result(true, undefined, someError); // Compile‑time error
const success = Result.success(data);
const failure = Result.error(someError);Memory Efficiency
const result = Result.error(new InvalidMailException());
throw new Error("Invalid email");Defining Structured Application Errors
First, create a developer‑friendly, machine‑readable error type:
export class AppError extends Error {
constructor(public readonly code: string, public readonly description: string) {
super(description);
this.name = 'AppError';
}
}
export class FollowerErrors {
static readonly SAME_USER = new AppError('FOLLOWERS_SAME_USER', 'Cannot follow yourself');
static readonly NON_PUBLIC_PROFILE = new AppError('FOLLOWERS_NON_PUBLIC_PROFILE', 'Cannot follow non‑public profiles');
static readonly ALREADY_FOLLOWING = new AppError('FOLLOWERS_ALREADY_FOLLOWING', 'Already following this user');
static readonly USER_NOT_FOUND = new AppError('FOLLOWERS_USER_NOT_FOUND', 'User not found');
static readonly DATABASE_ERROR = new AppError('FOLLOWERS_DATABASE_ERROR', 'Failed to save follower relationship');
}Benefits:
🔍 Unique error codes for easy identification
📖 Human‑readable descriptions improve UX
🎯 Centralised definitions keep error handling consistent
Result Pattern in Real Code
Using the result pattern in a service makes the flow crystal clear:
class FollowerService {
constructor(private followerRepository: IFollowerRepository) {}
async startFollowing(user: User, followed: User): Promise<Result<void>> {
if (user.id === followed.id) {
return Result.error(FollowerErrors.SAME_USER);
}
if (!followed.hasPublicProfile) {
return Result.error(FollowerErrors.NON_PUBLIC_PROFILE);
}
const isAlreadyFollowing = await this.followerRepository.isAlreadyFollowing(user.id, followed.id);
if (isAlreadyFollowing) {
return Result.error(FollowerErrors.ALREADY_FOLLOWING);
}
try {
await this.followerRepository.addFollower(user.id, followed.id);
return Result.success();
} catch (error) {
return Result.error(FollowerErrors.DATABASE_ERROR);
}
}
}
class FollowerRepository implements IFollowerRepository {
async getUserById(id: string): Promise<Result<User>> {
const user = await this.database.findUser(id);
if (!user) {
return Result.error(FollowerErrors.USER_NOT_FOUND);
}
return Result.success(user);
}
async isAlreadyFollowing(userId: string, followedId: string): Promise<boolean> {
return await this.database.checkFollowingRelation(userId, followedId);
}
async addFollower(userId: string, followedId: string): Promise<void> {
await this.database.insertFollower({ userId, followedId });
}
}Advantages:
📋 Clear method signatures show possible failures
🎯 Explicit error handling for each scenario
🏷️ Structured, identifiable errors
🛡️ Type‑safe error handling
Testing the Result Pattern
Tests become simpler and more reliable:
describe('FollowerService', () => {
let followerService: FollowerService;
let mockRepository: jest.Mocked<IFollowerRepository>;
beforeEach(() => {
mockRepository = { isAlreadyFollowing: jest.fn(), addFollower: jest.fn() } as any;
followerService = new FollowerService(mockRepository);
});
it('🚫 should return error when user tries to follow themselves', async () => {
const user: User = { id: '1', email: '[email protected]', hasPublicProfile: true };
const result = await followerService.startFollowing(user, user);
expect(result.isFailure()).toBe(true);
expect(result.getError()).toBe(FollowerErrors.SAME_USER);
});
it('🔒 should return error when trying to follow non‑public profile', async () => {
const user: User = { id: '1', email: '[email protected]', hasPublicProfile: true };
const privateUser: User = { id: '2', email: '[email protected]', hasPublicProfile: false };
const result = await followerService.startFollowing(user, privateUser);
expect(result.isFailure()).toBe(true);
expect(result.getError()).toBe(FollowerErrors.NON_PUBLIC_PROFILE);
});
it('🎉 should return success when following is valid', async () => {
const user: User = { id: '1', email: '[email protected]', hasPublicProfile: true };
const followed: User = { id: '2', email: '[email protected]', hasPublicProfile: true };
mockRepository.isAlreadyFollowing.mockResolvedValue(false);
const result = await followerService.startFollowing(user, followed);
expect(result.isSuccess()).toBe(true);
expect(mockRepository.addFollower).toHaveBeenCalledWith('1', '2');
});
});Testing benefits:
🎯 Precise error checks – no string matching
🧹 Cleaner test code – no try/catch blocks
📊 Better coverage – all error paths are easy to test
🚀 Faster tests – no exception overhead
When to Still Use Exceptions
Exceptions should be reserved for truly exceptional situations:
🔥 System failures (out‑of‑memory, network timeouts)
🐛 Programming errors (null reference, invalid configuration)
📚 Unpredictable external library failures
🚫 Violations of pre‑conditions or invariants
Conclusion
The result pattern transforms error handling from implicit and unpredictable to explicit and manageable. By bringing failures into method signatures, you can build more reliable, testable, and maintainable TypeScript applications.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Code Mala Tang
Read source code together, write articles together, and enjoy spicy hot pot together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
