Skip to content

Error Handling Guide - neverthrow & AppError

CRITICAL: This project uses ONLY neverthrow and AppError for error handling. Never use generic Error or unstructured error handling.

⚠️ Project Constraints (ZERO TOLERANCE)

Before using this guide, understand these absolute requirements:

  • ZERO TOLERANCE for any - Never use any under any circumstances
  • ZERO TOLERANCE for as unknown as - Never use as unknown as under any circumstances
  • ZERO TOLERANCE for unknown as a cop-out - Only use unknown when truly unknown, then narrow immediately
  • ZERO TOLERANCE for generic Error - Always use AppError hierarchy or Result types
  • Explicit types always - Define interfaces for all data structures
  • All types in apps/backend/src/types/ - Never define types inline or in random files

Philosophy

TendSocial uses type-safe error handling with two complementary approaches:

  1. AppError hierarchy - Structured errors with HTTP status codes and context
  2. Result<T, E> pattern - Functional error handling without exceptions

AppError Hierarchy

Base Class: AppError

All custom errors extend AppError:

typescript
import { AppError } from '@/infra/errors.js';

export class AppError extends Error {
    readonly code: string;           // Error code (e.g., "VALIDATION_ERROR")
    readonly statusCode: number;     // HTTP status code (e.g., 400)
    readonly isOperational: boolean; // true = expected error, false = programmer error
    readonly context?: JsonObject;   // Additional structured data
}

Built-in Error Classes

typescript
import {
    ValidationError,    // 400 - Bad input
    NotFoundError,      // 404 - Resource not found
    AuthError,          // 401 - Not authenticated
    ForbiddenError,     // 403 - Not authorized
    PlatformError,      // 502 - External API error
    RateLimitError,     // 429 - Rate limited
    ConfigError,        // 500 - Configuration error
    ConflictError,      // 409 - Resource conflict
    TimeoutError,       // 504 - Operation timeout
} from '@/infra/errors.js';

Creating AppError Instances

1. Using Specific Error Classes

typescript
// Validation error
throw new ValidationError('Email is required', 'email');

// Not found
throw new NotFoundError('User', userId);

// Authentication
throw new AuthError('Invalid credentials');

// Authorization
throw new ForbiddenError('Admin access required');

// Platform error
throw new PlatformError('twitter', 'Rate limit exceeded', {
    retryable: true,
    retryAfter: 60,
});

// Rate limit
throw new RateLimitError('linkedin', 60); // retryAfter in seconds

// Configuration
throw new ConfigError('Missing DATABASE_URL environment variable');

// Conflict
throw new ConflictError('Campaign', 'Campaign already exists');

// Timeout
throw new TimeoutError('API request', 5000); // operation, timeoutMs

2. From Caught Values

Use AppError.from() to normalize any caught value:

typescript
try {
    await externalApiCall();
} catch (error) {
    // Converts any error to AppError
    throw AppError.from(error);
}

// With additional context
try {
    await database.query(sql);
} catch (error) {
    throw AppError.from(error, { query: sql, userId });
}

Error Context

Add structured context to any error:

typescript
throw new NotFoundError('Campaign', campaignId, {
    userId,
    companyId,
    timestamp: new Date().toISOString(),
});

// Context is available for logging and debugging
const error = new ValidationError('Invalid email', 'email', {
    attemptedValue: 'not-an-email',
    validationRule: 'email-format',
});

Result<T, E> Pattern

Why Use Result?

Benefits over traditional try/catch:

  • Type-safe: Errors are part of the type signature
  • Explicit: Forces error handling at compile time
  • Composable: Chain operations with .map(), .andThen()
  • No silent failures: Can't forget to handle errors

Basic Usage

Importing

typescript
import { Result, ResultAsync, ok, err } from '@/infra/result.js';
// Or for types only:
import type { Result, ResultAsync } from '@/types/index.js';

Synchronous Results

typescript
function divide(a: number, b: number): Result<number, string> {
    if (b === 0) {
        return err('Division by zero');
    }
    return ok(a / b);
}

// Usage
const result = divide(10, 2);
result.match(
    (value) => console.log(`Result: ${value}`),  // 5
    (error) => console.error(`Error: ${error}`)
);

Asynchronous Results

typescript
function fetchUser(id: string): ResultAsync<User, AppError> {
    return ResultAsync.fromPromise(
        db.user.findUnique({ where: { id } }),
        (error) => AppError.from(error, { userId: id })
    );
}

// Usage
const result = await fetchUser('123');
result.match(
    (user) => console.log(user.name),
    (error) => console.error(error.message)
);

Chaining Operations

.map() - Transform success value

typescript
const result = await fetchUser('123')
    .map(user => user.email)
    .map(email => email.toLowerCase());

// Result<string, AppError>

.mapErr() - Transform error

typescript
const result = await fetchUser('123')
    .mapErr(err => new NotFoundError('User', '123'));

.andThen() - Chain async operations

typescript
const result = await fetchUser('123')
    .andThen(user => fetchPosts(user.id))
    .andThen(posts => publishPosts(posts));

// Stops at first error

Handling Results

Pattern Matching

typescript
const user = await fetchUser('123');

user.match(
    // Success case
    (user) => {
        console.log(`Found: ${user.name}`);
        return user;
    },
    // Error case
    (error) => {
        console.error(`Error: ${error.message}`);
        throw error;
    }
);

Unwrapping

typescript
// Get value or throw
const user = result._unsafeUnwrap(); // throws if error

// Get error or throw
const error = result._unsafeUnwrapErr(); // throws if ok

// Get value or default
const user = result.unwrapOr(defaultUser);

isOk / isErr Checks

typescript
if (result.isOk()) {
    const user = result.value; // Type-safe access
}

if (result.isErr()) {
    const error = result.error; // Type-safe access
}

Combining Results

typescript
import { combine, combineWithAllErrors } from '@/infra/result.js';

// Combine - stops at first error
const results = [
    fetchUser('1'),
    fetchUser('2'),
    fetchUser('3'),
];

const combined = combine(results);
// Result<User[], AppError>

// Combine with all errors
const combinedAll = combineWithAllErrors(results);
// Result<User[], AppError[]>

Integration with Fastify Routes

typescript
import { ResultAsync } from '@/infra/result.js';
import { NotFoundError } from '@/infra/errors.js';

app.get('/users/:id', async (request, reply) => {
    const result = await fetchUser(request.params.id);

    return result.match(
        (user) => reply.send(user),
        (error) => {
            if (error instanceof NotFoundError) {
                return reply.status(404).send({ error: error.message });
            }
            return reply.status(500).send({ error: 'Internal error' });
        }
    );
});

Best Practices

✅ DO

typescript
// Use specific error types
throw new NotFoundError('Campaign', id);

// Add context to errors
throw AppError.from(error, { userId, action: 'publish' });

// Use Result for fallible operations
function parseConfig(raw: string): Result<Config, ValidationError> {
    try {
        return ok(JSON.parse(raw));
    } catch {
        return err(new ValidationError('Invalid JSON config'));
    }
}

// Chain Result operations
return fetchUser(id)
    .andThen(user => validateUser(user))
    .andThen(user => saveUser(user));

// Handle all error cases
result.match(
    (data) => processSuccess(data),
    (error) => {
        if (error instanceof ValidationError) {
            return handleValidation(error);
        }
        if (error instanceof NotFoundError) {
            return handleNotFound(error);
        }
        return handleUnknown(error);
    }
);

❌ DON'T

typescript
// Don't use generic Error
throw new Error('Something went wrong'); // Use AppError instead

// Don't lose error context
throw new Error(error.message); // Use AppError.from(error) instead

// Don't ignore Result errors
const result = await fetchUser(id);
const user = result._unsafeUnwrap(); // Can throw! Use .match() instead

// Don't mix patterns inconsistently
async function badPattern() {
    const result = await fetchUser(id);
    if (result.isErr()) {
        throw result.error; // Either use Result throughout or convert early
    }
    // ... more code
}

Examples

Service with Result Pattern

typescript
import { ResultAsync, ok, err } from '@/infra/result.js';
import { NotFoundError, ValidationError } from '@/infra/errors.js';
import { getTenantPrisma } from '@/infra/prisma.js';

export class CampaignService {
    async getCampaign(
        companyId: string,
        campaignId: string
    ): ResultAsync<Campaign, NotFoundError> {
        const prisma = getTenantPrisma(companyId);

        return ResultAsync.fromPromise(
            prisma.campaign.findUnique({ where: { id: campaignId } }),
            (error) => AppError.from(error, { companyId, campaignId })
        ).andThen((campaign) => {
            if (!campaign) {
                return err(new NotFoundError('Campaign', campaignId));
            }
            return ok(campaign);
        });
    }

    async updateCampaign(
        companyId: string,
        campaignId: string,
        data: CampaignUpdate
    ): ResultAsync<Campaign, ValidationError | NotFoundError> {
        return this.getCampaign(companyId, campaignId)
            .andThen((campaign) => {
                if (campaign.status === 'ARCHIVED') {
                    return err(new ValidationError('Cannot update archived campaign'));
                }
                return ok(campaign);
            })
            .andThen(() => {
                const prisma = getTenantPrisma(companyId);
                return ResultAsync.fromPromise(
                    prisma.campaign.update({
                        where: { id: campaignId },
                        data,
                    }),
                    (error) => AppError.from(error)
                );
            });
    }
}

Fastify Route Handler

typescript
import { ResultAsync } from '@/infra/result.js';
import { NotFoundError, ForbiddenError } from '@/infra/errors.js';

app.get('/campaigns/:id', {
    schema: {
        params: z.object({ id: z.string().uuid() }),
        response: {
            200: CampaignSchema,
        },
    },
}, async (request, reply) => {
    const { userId, companyId } = request.user;
    const { id } = request.params;

    const result = await campaignService
        .getCampaign(companyId, id)
        .andThen((campaign) => {
            // Authorization check
            if (campaign.companyId !== companyId) {
                return ResultAsync.fromSafePromise(
                    Promise.reject(new ForbiddenError())
                );
            }
            return ResultAsync.fromSafePromise(Promise.resolve(campaign));
        });

    return result.match(
        (campaign) => reply.send(campaign),
        (error) => {
            if (error instanceof NotFoundError) {
                return reply.status(404).send({ error: error.message });
            }
            if (error instanceof ForbiddenError) {
                return reply.status(403).send({ error: error.message });
            }
            return reply.status(500).send({ error: 'Internal error' });
        }
    );
});

Traditional Try/Catch (When to Use)

Use traditional try/catch for:

  1. Top-level error boundaries
  2. Logging and re-throwing
  3. Simple operations where Result adds complexity
typescript
import { logError } from '@/infra/logger.js';

export class PostPublisher {
    async publish(postId: string, platforms: string[]) {
        try {
            const post = await this.getPost(postId);
            const results = await Promise.allSettled(
                platforms.map(p => this.publishToPlatform(post, p))
            );

            return this.aggregateResults(results);
        } catch (error) {
            // Convert to AppError and log
            const appError = AppError.from(error, { postId, platforms });
            logError(this.logger, appError, 'Post publishing failed');
            throw appError;
        }
    }
}

Migration Guide

Converting from Throws to Result

Before:

typescript
async function getUser(id: string): Promise<User> {
    const user = await db.user.findUnique({ where: { id } });
    if (!user) {
        throw new NotFoundError('User', id);
    }
    return user;
}

After:

typescript
function getUser(id: string): ResultAsync<User, NotFoundError> {
    return ResultAsync.fromPromise(
        db.user.findUnique({ where: { id } }),
        (error) => AppError.from(error)
    ).andThen((user) => {
        if (!user) {
            return err(new NotFoundError('User', id));
        }
        return ok(user);
    });
}

TendSocial Documentation