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.

Code Mala Tang
Code Mala Tang
Code Mala Tang
Hidden Costs of Throwing Exceptions and the Result Pattern Solution

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&lt;User&gt; 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 error

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

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

TypeScriptError HandlingExceptionsresult pattern
Code Mala Tang
Written by

Code Mala Tang

Read source code together, write articles together, and enjoy spicy hot pot together.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.