Lesson 38 of 54

Error Handling Done Right

Writing Useful Error Messages

I spent two hours debugging a production incident once. The error log said: "Invalid input."

That's it. No stack trace. No context. No indication of which input, which endpoint, or which user. We had to add logging, redeploy, and wait for it to happen again.

A useful error message would have saved us both hours and stress. Here's how to write them.

What Makes an Error Message Useful

A useful error message answers three questions:

  1. What happened? — The failure in plain language
  2. Why did it happen? — Context that helps diagnose
  3. How do I fix it? — Actionable guidance when possible

Terrible vs. Great

Javascript
// TERRIBLE: No context, no action
throw new Error('Invalid input');

// BETTER: What and why
throw new Error('User 123: expected email, got null');

// GREAT: What, why, and how
throw new Error(
  'User 123: expected email, got null. ' +
  'Ensure the registration form includes a valid email before calling createUser().'
);

The terrible version forces you to search the codebase. The great version tells you exactly where to look and what to check.

Including Context

Context is the difference between "something broke" and "I know what to do."

What to Include

  • Entity identifiers — User ID, order ID, request ID. "Order 456 failed" beats "Order failed"
  • Expected vs. actual — "expected string, got number" beats "type error"
  • Relevant state — "Cannot withdraw: balance 50, requested 100"
  • Where in the flow — "Failed during payment step" or "Failed to persist after validation"

Before: Vague Errors

Javascript
async function saveOrder(order) {
  if (!order.items?.length) {
    throw new Error('Invalid order');
  }
  const result = await db.orders.insert(order);
  if (!result) {
    throw new Error('Save failed');
  }
  return result;
}

After: Context-Rich Errors

Javascript
async function saveOrder(order) {
  if (!order.items?.length) {
    throw new Error(
      `Order ${order.id ?? 'unknown'}: cannot save order with no items. ` +
      `Received ${order.items?.length ?? 0} items.`
    );
  }
  const result = await db.orders.insert(order);
  if (!result) {
    throw new Error(
      `Failed to persist order ${order.id} to database. ` +
      `Insert returned null. Check DB connection and constraints.`
    );
  }
  return result;
}

Preserving Stack Traces

Stack traces are gold for debugging. Don't lose them.

Chaining Errors

When you catch and rethrow, preserve the original error:

Javascript
// BAD: Loses the original stack
try {
  await processPayment(payment);
} catch (err) {
  throw new Error('Payment failed');  // Original stack is gone!
}

// GOOD: Chain with cause (ES2022+)
try {
  await processPayment(payment);
} catch (err) {
  throw new Error(`Payment ${payment.id} failed`, { cause: err });
}

The cause option preserves the original error. Logging tools and console will show both stacks.

Pre-ES2022 Pattern

Javascript
try {
  await processPayment(payment);
} catch (err) {
  const wrapped = new Error(`Payment ${payment.id} failed: ${err.message}`);
  wrapped.originalError = err;
  wrapped.stack = `${wrapped.stack}\nCaused by: ${err.stack}`;
  throw wrapped;
}

Structured Errors vs. String Messages

Sometimes a string isn't enough. Structured errors give you machine-readable data for logging and monitoring.

When to Use Structured Errors

  • You need to route errors differently (retry vs. fail vs. alert)
  • You're sending errors to a monitoring system (Datadog, Sentry)
  • You need to localize user-facing messages
  • You want to attach metadata (request ID, user ID, timestamp)

TypeScript: Error Classes with Metadata

Typescript
class ValidationError extends Error {
  constructor(
    message: string,
    public readonly field: string,
    public readonly value: unknown,
    public readonly code: string = 'VALIDATION_ERROR'
  ) {
    super(message);
    this.name = 'ValidationError';
  }

  toJSON() {
    return {
      name: this.name,
      message: this.message,
      field: this.field,
      code: this.code,
    };
  }
}

// Usage
throw new ValidationError(
  `Invalid email format: "${email}"`,
  'email',
  email,
  'INVALID_EMAIL'
);

Now your error handler can check err.code, your logger can serialize err.toJSON(), and your monitoring can group by err.field.

Result Type with Structured Errors

Typescript
type Result<T, E = AppError> = 
  | { ok: true; value: T }
  | { ok: false; error: E };

interface AppError {
  code: string;
  message: string;
  context?: Record<string, unknown>;
}

function validateEmail(email: string): Result<string, AppError> {
  if (!email.includes('@')) {
    return {
      ok: false,
      error: {
        code: 'INVALID_EMAIL',
        message: `Invalid email format: "${email}"`,
        context: { field: 'email', received: email },
      },
    };
  }
  return { ok: true, value: email };
}

Logging vs. User-Facing Errors

Never show internal error details to users. Never hide useful details from developers.

The Split

AudienceWhat they needExample
UserSomething went wrong, what to try"We couldn't save your order. Please try again or contact support."
DeveloperFull context, stack, IDs"Order 456: DB insert failed: duplicate key on orders.user_id. Stack: ..."

Implementation

Typescript
function handleApiError(err: unknown, requestId: string): ApiResponse {
  // Log everything for developers
  logger.error({
    message: err instanceof Error ? err.message : String(err),
    stack: err instanceof Error ? err.stack : undefined,
    requestId,
    ...(err instanceof ValidationError && { field: err.field }),
  });

  // Return sanitized response to user
  const userMessage = err instanceof ValidationError
    ? `Invalid ${err.field}. Please check your input.`
    : 'Something went wrong. Please try again.';

  return {
    status: 500,
    body: {
      error: userMessage,
      requestId,  // User can give this to support
    },
  };
}

The user gets a safe, actionable message. Support gets a request ID to look up the full error. Developers get the full picture in logs.

Examples: Terrible vs. Great

Null Reference

Javascript
// Terrible
throw new Error('Cannot read property of undefined');

// Great
throw new Error(
  `Cannot read 'email' of undefined. ` +
  `User object was null when calling sendWelcomeEmail(user). ` +
  `Ensure user is loaded before sending.`
);

Network Failure

Javascript
// Terrible
throw new Error('Request failed');

// Great
throw new Error(
  `Payment API request failed: GET https://api.payments.com/status returned 503. ` +
  `Request ID: ${response.headers['x-request-id']}. ` +
  `Retry after ${response.headers['retry-after']} seconds.`
);

Validation

Javascript
// Terrible
throw new Error('Invalid');

// Great
throw new ValidationError(
  `Amount must be positive, got ${amount}`,
  'amount',
  amount
);

Anti-Patterns to Avoid

Swallowing Context

Javascript
// BAD
catch (err) {
  throw new Error('Failed');
}

// GOOD
catch (err) {
  throw new Error(`Failed to process order: ${err.message}`, { cause: err });
}

Exposing Internals to Users

Javascript
// BAD: User sees SQL
return { error: `Query failed: SELECT * FROM users WHERE id = ${id}` };

// GOOD
logger.error(`Query failed`, { query, id });
return { error: 'We couldn\'t find that user. Please try again.' };

Generic Catch-All Messages

Javascript
// BAD: Same message for everything
throw new Error('An error occurred');

// GOOD: Specific to the failure
throw new Error(`Order ${orderId} could not be shipped: address validation failed`);

Key insight: A useful error message answers what happened, why, and how to fix it. Include entity IDs, expected vs. actual values, and preserve stack traces when rethrowing. Split user-facing messages (safe, actionable) from developer-facing logs (full context). Structure errors when you need machine-readable data for routing or monitoring.