Error Handling Guide - neverthrow & AppError
CRITICAL: This project uses ONLY neverthrow and AppError for error handling. Never use generic
Erroror unstructured error handling.
⚠️ Project Constraints (ZERO TOLERANCE)
Before using this guide, understand these absolute requirements:
- ZERO TOLERANCE for
any- Never useanyunder any circumstances - ZERO TOLERANCE for
as unknown as- Never useas unknown asunder any circumstances - ZERO TOLERANCE for
unknownas a cop-out - Only useunknownwhen 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:
- AppError hierarchy - Structured errors with HTTP status codes and context
- 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, timeoutMs2. 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 errorHandling 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:
- Top-level error boundaries
- Logging and re-throwing
- 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);
});
}