Skip to main content

Neverthrow Migration Guide

This guide documents the migration from exception-based error handling to neverthrow Result<T, DomainError> patterns across the Asset360 backend. This migration provides type-safe error handling, better debugging, and more reliable code.

Overview

Why Neverthrow?

Traditional exception-based error handling has several issues in financial applications:

  1. Hidden Control Flow: Exceptions create invisible code paths that are hard to track
  2. Type Safety Loss: Error types are lost when caught as generic Error objects
  3. Testing Complexity: Error scenarios require complex try/catch setups
  4. Debugging Difficulty: Stack traces don't always show business context
  5. Audit Trail Gaps: Error context is often lost in exception handling

Benefits of Result<T, DomainError>

// Before: Exception-based pattern
async processPayment(amount: number): Promise<Transaction> {
if (amount <= 0) throw new Error("Amount must be positive");
if (account.balance < amount) throw new Error("Insufficient funds");

const transaction = await createTransaction(amount);
return transaction;
}

// After: Result-based pattern
async processPayment(amount: number): Promise<Result<Transaction, PaymentError>> {
if (amount <= 0) {
return err(ValidationError.invalidAmount("Amount must be positive"));
}

const accountResult = await getAccount(accountId);
if (accountResult.isErr()) {
return err(accountResult.error);
}

if (accountResult.value.balance < amount) {
return err(BusinessLogicError.insufficientFunds(
accountResult.value.balance,
amount
));
}

const transactionResult = await createTransaction(amount);
return transactionResult;
}

Migration Status

✅ Fully Migrated

These services use Result<T, DomainError> for all public methods:

ServiceStatusError TypesLocation
Domain Errors✅ CompleteComprehensive error type system@shared/domain-errors.ts
Equity Portfolio✅ CompleteDomainError typesworker/services/portfolio/equity/
Accounting✅ CompleteDomainError typesworker/services/accounting/
Bank Account✅ CompleteDomainError typesworker/services/bank-account/
FDR✅ CompleteDomainError typesworker/services/fdr/
Fund✅ CompleteDomainError typesworker/services/fund/
Investor✅ CompleteDomainError typesworker/services/investor/
Organization✅ CompleteDomainError typesworker/services/organization/
Market Data✅ CompleteDomainError typesworker/services/market-data/
Authentication✅ CompleteDomainError typesworker/services/auth/
Unrealized Gain/Loss✅ CompleteDomainError typesworker/services/accounting/

🔄 In Progress

ServiceStatusCurrent PatternTarget
Bond Portfolio🔄 PartialMixed Results/ExceptionsFull Result<T, DomainError>

⏳ Pending Migration

ServiceCurrent PatternTarget StatusPriority
Bond PortfolioException-basedResult<T, BondError>High
PurchaseBond Use CaseException-basedResult<T, PurchaseBondError>High

Domain Error System

Core Error Types

export type DomainError =
| ValidationError
| NotFoundError
| InvariantViolation
| BusinessRuleViolation
| ConcurrencyError
| InsufficientUnits
| InsufficientFunds
| DatabaseError
| AuthenticationError
| AuthorizationError;

Error Type Definitions

// Validation errors for input validation failures
export interface ValidationError {
type: "ValidationError";
message: string;
field?: string;
details?: Record<string, string>;
}

// Resource not found errors
export interface NotFoundError {
type: "NotFoundError";
entity: string;
id: string;
}

// Business rule violations
export interface BusinessRuleViolation {
type: "BusinessRuleViolation";
rule: string;
message: string;
context?: Record<string, unknown>;
}

// Insufficient resources errors
export interface InsufficientUnits {
type: "InsufficientUnits";
available: number;
requested: number;
holdingId?: string;
}

export interface InsufficientFunds {
type: "InsufficientFunds";
available: number;
required: number;
accountId: string;
}

Error Creation Helpers

import {
ValidationError,
NotFoundError,
BusinessRuleViolation,
InsufficientFunds,
} from "@shared/domain-errors";

// ValidationError helpers
export const validationError = (
message: string,
field?: string,
details?: Record<string, string>,
): ValidationError => ({
type: "ValidationError",
message,
field,
details,
});

// NotFoundError helpers
export const notFoundError = (entity: string, id: string): NotFoundError => ({
type: "NotFoundError",
entity,
id,
});

// Business rule error helpers
export const businessRuleError = (
rule: string,
message: string,
context?: Record<string, unknown>,
): BusinessRuleViolation => ({
type: "BusinessRuleViolation",
rule,
message,
context,
});

// Insufficient funds helper
export const insufficientFundsError = (
available: number,
required: number,
accountId: string,
): InsufficientFunds => ({
type: "InsufficientFunds",
available,
required,
accountId,
});

Migration Patterns

1. Method Signature Changes

Before:

interface TransactionService {
createTransaction(data: CreateTransactionRequest): Promise<Transaction>;
getTransaction(id: string): Promise<Transaction | null>;
}

After:

interface TransactionService {
createTransaction(
data: CreateTransactionRequest,
): Promise<Result<Transaction, DomainError>>;
getTransaction(id: string): Promise<Result<Transaction, NotFoundError>>;
}

2. Error Handling Patterns

Before:

async processTransaction(request: ProcessRequest): Promise<void> {
try {
const transaction = await this.transactionService.createTransaction(request);
await this.accountingService.recordTransaction(transaction);
await this.notificationService.sendConfirmation(transaction);
} catch (error) {
logger.error("Transaction failed", error);
throw new Error(`Transaction processing failed: ${error.message}`);
}
}

After:

async processTransaction(request: ProcessRequest): Promise<Result<void, ProcessTransactionError>> {
// Input validation
if (!request.isValid()) {
return err(ProcessTransactionError.validation("Invalid request data"));
}

// Create transaction
const transactionResult = await this.transactionService.createTransaction(request);
if (transactionResult.isErr()) {
return err(transactionResult.error);
}
const transaction = transactionResult.value;

// Record in accounting
const accountingResult = await this.accountingService.recordTransaction(transaction);
if (accountingResult.isErr()) {
return err(accountingResult.error);
}

// Send notification (non-critical)
const notificationResult = await this.notificationService.sendConfirmation(transaction);
if (notificationResult.isErr()) {
logger.warn("Notification failed", notificationResult.error);
// Don't fail the operation for notification failures
}

return ok(undefined);
}

3. Railway-Oriented Programming

import { pipe } from "neverthrow";

// Chain operations with Result handling
const processPayment = pipe(
validatePaymentRequest,
(request) => checkAccountBalance(request.accountId, request.amount),
(request) => createTransaction(request),
(transaction) => updateAccountBalance(transaction),
(transaction) => sendConfirmation(transaction),
);

// Usage
const result = await processPayment(paymentRequest);

if (result.isOk()) {
console.log("Payment processed successfully");
} else {
handlePaymentError(result.error);
}

4. Error Mapping and Transformation

// Transform domain errors to use case errors
const mapToUseCaseError = (domainError: DomainError): UseCaseError => {
switch (domainError.type) {
case "ValidationError":
return UseCaseError.validation(domainError.message, domainError.field);
case "NotFoundError":
return UseCaseError.notFound(domainError.entity, domainError.id);
case "InsufficientFunds":
return UseCaseError.insufficientFunds(
domainError.available,
domainError.required,
domainError.accountId,
);
default:
return UseCaseError.system("UNKNOWN_ERROR", "An unknown error occurred");
}
};

Use Case Migration Patterns

Custom Error Types

Use cases should define their own specific error types while composing domain errors:

export type PurchaseUnitsError =
| ValidationError
| BusinessLogicError
| SystemError
| NotFoundError;

export interface ValidationError {
type: "ValidationError";
field: string;
message: string;
value?: unknown;
}

export interface BusinessLogicError {
type: "BusinessLogicError";
code: string;
message: string;
details?: Record<string, unknown>;
}

export interface SystemError {
type: "SystemError";
code: string;
message: string;
originalError?: Error;
details?: Record<string, unknown>;
}

export interface NotFoundError {
type: "NotFoundError";
resource: string;
id: string;
message: string;
}

Error Creation Factories

export const ValidationError = {
positiveUnits: (): ValidationError => ({
type: "ValidationError",
field: "units",
message: "Units must be positive",
}),
positiveNav: (): ValidationError => ({
type: "ValidationError",
field: "navPerUnit",
message: "NAV per unit must be positive",
}),
custom: (
field: string,
message: string,
value?: unknown,
): ValidationError => ({
type: "ValidationError",
field,
message,
value,
}),
};

export const BusinessLogicError = {
accessDenied: (message: string): BusinessLogicError => ({
type: "BusinessLogicError",
code: "ACCESS_DENIED",
message,
}),
insufficientFunds: (
available: number,
required: number,
): BusinessLogicError => ({
type: "BusinessLogicError",
code: "INSUFFICIENT_FUNDS",
message: `Insufficient funds: ${available} available, ${required} required`,
details: { available, required },
}),
};

Testing with Result Patterns

Unit Testing

describe("TransactionService", () => {
it("should create transaction with valid data", async () => {
const mockRepository = {
save: vi.fn().mockResolvedValue({ id: "tx-123", ...validTransaction }),
};

const service = new TransactionService(mockRepository);
const result = await service.createTransaction(validRequest);

expect(result.isOk()).toBe(true);
expect(result.value.id).toBe("tx-123");
});

it("should return validation error for invalid amount", async () => {
const service = new TransactionService(mockRepository);
const invalidRequest = { ...validRequest, amount: -100 };

const result = await service.createTransaction(invalidRequest);

expect(result.isErr()).toBe(true);
if (result.isErr()) {
expect(result.error.type).toBe("ValidationError");
expect(result.error.field).toBe("amount");
}
});

it("should return insufficient funds error", async () => {
const mockAccountService = {
getBalance: vi.fn().mockResolvedValue(ok({ balance: 100 })),
};

const service = new TransactionService(mockAccountService, mockRepository);
const request = { ...validRequest, amount: 200 }; // More than balance

const result = await service.createTransaction(request);

expect(result.isErr()).toBe(true);
if (result.isErr()) {
expect(result.error.type).toBe("InsufficientFunds");
expect(result.error.available).toBe(100);
expect(result.error.required).toBe(200);
}
});
});

Integration Testing

describe("Purchase Units Integration", () => {
it("should complete full purchase workflow", async () => {
const services = createTestServices();
const useCases = createApplicationServices(services);

// Setup fund and investor
const fund = await setupFund(services);
const investor = await setupInvestor(services);

// Execute purchase
const result = await useCases.unitPurchase.execute({
investorId: investor.id,
fundId: fund.id,
units: 100,
navPerUnit: { amount: 1000, currency: "BDT" },
});

// Verify successful Result
expect(result.isOk()).toBe(true);
if (result.isOk()) {
expect(result.value.transactionId).toBeDefined();
expect(result.value.holding.totalUnits).toBe(100);
}

// Verify side effects
const holdings = await services.investor.getInvestorHoldings(investor.id);
const fundHolding = holdings.find((h) => h.fundId === fund.id);
expect(fundHolding?.totalUnits).toBe(100);
});
});

Mocking Results

// Mock service to return Results
const mockInvestorService = {
getInvestorById: vi.fn().mockImplementation((id) => {
if (id === "invalid") {
return Promise.resolve(err(notFoundError("Investor", id)));
}
return Promise.resolve(ok({ id, name: "Test Investor" }));
}),
purchaseUnits: vi.fn().mockResolvedValue(ok({ transactionId: "tx-123" })),
};

Best Practices

1. Error Type Design

  • Be Specific: Create error types for specific business scenarios
  • Include Context: Add relevant data (IDs, values, business context)
  • Use Discriminated Unions: Enable type-safe error handling
  • Compose Errors: Combine domain errors in use case errors

2. Result Handling

// ✅ Good: Handle errors explicitly
const result = await service.operation(data);
if (result.isErr()) {
logger.error("Operation failed", { error: result.error });
return err(result.error);
}
const value = result.value;

// ✅ Good: Use match for pattern matching
result.match(
(value) => processSuccess(value),
(error) => handleError(error),
);

// ❌ Bad: Unsafe unwrap
const value = (await service.operation(data))._unsafeUnwrap();

3. Error Propagation

// ✅ Good: Early return on error
async processMultipleSteps(request: ProcessRequest): Promise<Result<ProcessResult, ProcessError>> {
const step1Result = await this.step1(request);
if (step1Result.isErr()) return err(step1Result.error);

const step2Result = await this.step2(step1Result.value);
if (step2Result.isErr()) return err(step2Result.error);

return ok(await this.finalize(step2Result.value));
}

// ✅ Good: Use flatMap for chaining
import { flatMap } from "neverthrow";

const result = await step1(request)
.then(flatMap(step2))
.then(flatMap(step3));

4. Error Logging

// ✅ Good: Structured error logging
if (result.isErr()) {
const error = result.error;
logger.error("Operation failed", {
type: error.type,
message: error.message,
field: error.field,
value: error.value,
// Add business context
operation: "processPayment",
requestId: request.id,
userId: request.userId,
});
return err(error);
}

5. Testing Error Cases

// ✅ Good: Test specific error types
it("should return specific error type", async () => {
const result = await service.processInvalidRequest();

expect(result.isErr()).toBe(true);
if (result.isErr()) {
expect(result.error.type).toBe("ValidationError");
expect(result.error.field).toBe("amount");
expect(result.error.message).toContain("must be positive");
}
});

Performance Considerations

Result Object Overhead

  • Minimal Overhead: Result objects have minimal performance impact compared to exceptions
  • Memory Efficient: No stack trace generation for normal flow
  • Better Hot Paths: No exception handling overhead in success cases

Error Object Creation

// ✅ Good: Reuse error creation functions
export const insufficientFundsError = (
available: number,
required: number,
) => ({
type: "InsufficientFunds",
available,
required,
});

// ❌ Bad: Create error objects inline
return err({
type: "InsufficientFunds",
available,
required,
}); // Creates new object each time

Migration Checklist

Service Migration

  • Update method signatures to return Result<T, ErrorType>
  • Create custom error types for the service
  • Replace exception throwing with err() returns
  • Update method implementations to handle Results
  • Add comprehensive error context
  • Update tests to use Result patterns

Use Case Migration

  • Define use case-specific error types
  • Update execute() method signature
  • Add input validation with specific errors
  • Handle service Results with proper propagation
  • Add graceful degradation for non-critical failures
  • Update integration tests

Testing Updates

  • Update unit tests to check .isOk()/.isErr()
  • Test specific error types and context
  • Update integration tests for Result handling
  • Add error scenario tests
  • Mock services to return Results

Documentation Updates

  • Update API documentation with Result signatures
  • Document error types and their usage
  • Add migration notes and breaking changes
  • Update examples with Result handling
  • Document best practices

Common Migration Issues

Issue: Async Result Chaining

Problem: Chaining async Results can be verbose

const step1 = await operation1(data);
if (step1.isErr()) return err(step1.error);
const step2 = await operation2(step1.value);
if (step2.isErr()) return err(step2.error);

Solution: Use flatMap or pipe

import { flatMap, pipe } from "neverthrow";

const result = await pipe(
operation1(data),
flatMap(operation2),
flatMap(operation3),
);

Issue: Error Type Proliferation

Problem: Too many specific error types

export type MyError =
| InvalidEmailError
| InvalidPasswordError
| InvalidNameError
| InvalidPhoneError;

Solution: Use generic error types with field context

export type MyError = ValidationError;

return err(ValidationError.custom("email", "Invalid email format", email));

Issue: Non-Error Failures

Problem: Some operations don't fit error patterns

// What to return when operation succeeds but has no value?
async processNotification(): Promise<Result<void, DomainError>>

Solution: Use ok(undefined) for successful operations with no return value

return ok(undefined); // Success with no value

Tools and Utilities

Error Formatting

// Format errors for user display
export const formatError = (error: DomainError): string => {
switch (error.type) {
case "ValidationError":
return error.field
? `Validation failed for ${error.field}: ${error.message}`
: error.message;
case "NotFoundError":
return `${error.entity} with ID ${error.id} not found`;
case "InsufficientFunds":
return `Insufficient funds: ${error.available} available, ${error.required} required`;
default:
return error.message;
}
};

Result Utilities

// Convert promises to Results
export const safePromise = async <T>(
promise: Promise<T>,
): Promise<Result<T, SystemError>> => {
try {
const value = await promise;
return ok(value);
} catch (error) {
return err(SystemError.fromError(error));
}
};

// Transform Results
export const mapResult = <T, U, E>(
result: Result<T, E>,
fn: (value: T) => U,
): Result<U, E> => {
return result.map(fn);
};

Validation Helpers

// Common validation patterns
export const validatePositive = (
value: number,
field: string,
): Result<number, ValidationError> => {
if (value <= 0) {
return err(
ValidationError.custom(field, `${field} must be positive`, value),
);
}
return ok(value);
};

export const validateEmail = (
email: string,
): Result<string, ValidationError> => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return err(ValidationError.custom("email", "Invalid email format", email));
}
return ok(email);
};

Conclusion

The neverthrow migration provides significant benefits for financial applications:

  1. Type Safety: Compile-time guarantees about error handling
  2. Explicit Control Flow: All error paths are visible in code
  3. Better Testing: Deterministic error scenario testing
  4. Improved Debugging: Rich error context with business meaning
  5. Regulatory Compliance: Audit-friendly error handling

By following these patterns and best practices, you can create robust, maintainable, and type-safe error handling throughout your financial application.

Last Updated: 2025-01-11 - Comprehensive neverthrow migration guide with patterns, examples, and best practices