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:
- Hidden Control Flow: Exceptions create invisible code paths that are hard to track
- Type Safety Loss: Error types are lost when caught as generic
Errorobjects - Testing Complexity: Error scenarios require complex try/catch setups
- Debugging Difficulty: Stack traces don't always show business context
- 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:
| Service | Status | Error Types | Location |
|---|---|---|---|
| Domain Errors | ✅ Complete | Comprehensive error type system | @shared/domain-errors.ts |
| Equity Portfolio | ✅ Complete | DomainError types | worker/services/portfolio/equity/ |
| Accounting | ✅ Complete | DomainError types | worker/services/accounting/ |
| Bank Account | ✅ Complete | DomainError types | worker/services/bank-account/ |
| FDR | ✅ Complete | DomainError types | worker/services/fdr/ |
| Fund | ✅ Complete | DomainError types | worker/services/fund/ |
| Investor | ✅ Complete | DomainError types | worker/services/investor/ |
| Organization | ✅ Complete | DomainError types | worker/services/organization/ |
| Market Data | ✅ Complete | DomainError types | worker/services/market-data/ |
| Authentication | ✅ Complete | DomainError types | worker/services/auth/ |
| Unrealized Gain/Loss | ✅ Complete | DomainError types | worker/services/accounting/ |
🔄 In Progress
| Service | Status | Current Pattern | Target |
|---|---|---|---|
| Bond Portfolio | 🔄 Partial | Mixed Results/Exceptions | Full Result<T, DomainError> |
⏳ Pending Migration
| Service | Current Pattern | Target Status | Priority |
|---|---|---|---|
| Bond Portfolio | Exception-based | Result<T, BondError> | High |
| PurchaseBond Use Case | Exception-based | Result<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:
- Type Safety: Compile-time guarantees about error handling
- Explicit Control Flow: All error paths are visible in code
- Better Testing: Deterministic error scenario testing
- Improved Debugging: Rich error context with business meaning
- 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