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:
- What happened? — The failure in plain language
- Why did it happen? — Context that helps diagnose
- How do I fix it? — Actionable guidance when possible
Terrible vs. Great
// 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
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
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:
// 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
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
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
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
| Audience | What they need | Example |
|---|---|---|
| User | Something went wrong, what to try | "We couldn't save your order. Please try again or contact support." |
| Developer | Full context, stack, IDs | "Order 456: DB insert failed: duplicate key on orders.user_id. Stack: ..." |
Implementation
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
// 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
// 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
// Terrible
throw new Error('Invalid');
// Great
throw new ValidationError(
`Amount must be positive, got ${amount}`,
'amount',
amount
);
Anti-Patterns to Avoid
Swallowing Context
// 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
// 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
// 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.