portfolio Anshul Bisen
ask my work

Why every API endpoint at FinanceOps returns the same error shape

Our frontend had seven different error format handlers. I standardized every endpoint to return the same discriminated union error shape.

I was debugging a failed payment form submission and opened the error handler in our React code. Inside that handler was a cascade of if statements checking whether the error response had a message field, a messages array, an error string, a detail object, or an errors array. Seven different shapes for seven different endpoints, all written at different times by the same person (me), each reflecting whatever I thought an error response should look like that particular week.

That was the moment I decided every API endpoint at FinanceOps would return the exact same error shape. No exceptions. The standardization took two days and eliminated an entire category of frontend bugs. Here is what we landed on and why.

Where the data model or query started fighting back.

In that first stretch at FinanceOps, I was still learning how to wear the Head of Engineering title without hiding behind it. It also builds on what I learned earlier in “The PostgreSQL query that took 47 seconds and how I got it to 3 milliseconds.” The only credibility that mattered was whether the decision survived contact with real money, ugly edge cases, and the next person I would eventually hire. That same bias toward strict boundaries later shaped how I approached ftryos and pipeline-sdk: make correctness boring before you make the API clever.

The operational artifact behind the argument.

The Error Shape

Every error response from our API follows this TypeScript type. The shape is a discriminated union keyed on the code field, which is a machine-readable string that the frontend can switch on without parsing human-readable messages.

interface ApiError {
success: false;
error: {
code: string; // Machine-readable: "PAYMENT_AMOUNT_EXCEEDED"
message: string; // Human-readable: "Payment amount exceeds limit"
details?: FieldError[]; // Field-level validation errors
requestId: string; // Correlation ID for debugging
};
}
interface FieldError {
field: string; // "amount" or "recipient.email"
message: string; // "Must be between 1 and 100000"
code: string; // "RANGE_ERROR"
}
// Success responses follow the same wrapper
interface ApiSuccess<T> {
success: true;
data: T;
}

The success field is a boolean discriminator. The frontend checks response.success once and knows exactly what shape to expect. No guessing, no defensive field access, no try-catch around property reads.

The Middleware That Enforces It

Having a type definition is not enough. Every developer (including future me at midnight) needs to be unable to return a non-conforming error. The enforcement happens in Express middleware that wraps every route handler.

class AppError extends Error {
constructor(
public code: string,
message: string,
public statusCode: number = 400,
public details?: FieldError[],
) {
super(message);
}
}
// Global error handler middleware
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
const requestId = req.headers['x-request-id'] ?? crypto.randomUUID();
if (err instanceof AppError) {
return res.status(err.statusCode).json({
success: false,
error: {
code: err.code,
message: err.message,
details: err.details,
requestId,
},
});
}
// Unknown errors get a generic shape
console.error('Unhandled error:', err);
return res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId,
},
});
});

Every route handler throws AppError instances with specific codes. The middleware catches them and formats the response. If an unhandled error escapes, the middleware wraps it in the same shape with a generic code. The frontend never sees a raw Error object or an unexpected JSON structure.

The Client-Side Hook

On the React side, a single hook handles all API errors. Because every error has the same shape, the hook does not need per-endpoint logic.

function useApiError() {
return useCallback((error: ApiError['error']) => {
switch (error.code) {
case 'VALIDATION_ERROR':
// Set field-level errors from error.details
return { type: 'field', fields: error.details };
case 'PAYMENT_AMOUNT_EXCEEDED':
case 'INSUFFICIENT_BALANCE':
// Show inline business logic error
return { type: 'inline', message: error.message };
case 'UNAUTHORIZED':
// Redirect to login
return { type: 'redirect', to: '/login' };
default:
// Show generic toast with request ID for support
return {
type: 'toast',
message: `Something went wrong. Reference: ${error.requestId}`,
};
}
}, []);
}

The hook returns a structured action that the calling component can handle. Field errors get wired to form fields. Business logic errors get shown inline. Auth errors redirect. Everything else shows a toast with the request ID so the user can reference it in a support conversation and I can find the exact server log entry.

The Request ID Thread

The requestId in every error response is the most underrated feature of the entire system. Every API request gets a unique ID that flows through the entire request lifecycle: incoming request, database queries, external API calls, error logs, and the error response. When a user reports “the payment form showed an error,” I ask for the reference code and can trace the exact request through every log entry in under a minute.

  • The request ID is generated in middleware if the client does not provide one via X-Request-Id header.
  • It propagates to all database queries via a logging context so query logs are correlated.
  • It appears in the error response so the user can copy it from the error toast.
  • It is indexed in our log aggregation so I can search by request ID instantly.

Results After Six Weeks

  • Frontend error handling code reduced by 60%. Seven different error parsers replaced by one hook.
  • Zero bug reports from malformed error responses since the migration. Previously we averaged about one per week.
  • Mean time to debug client-reported errors dropped from 15 minutes to under 2 minutes because of request ID tracing.
  • New API endpoints take half the time to build because the error handling is standardized and automatic.
The system after the boring-but-correct fix.

The builder phase was less glamorous than people imagine. It was mostly a series of stubborn, unfashionable choices that kept future-me out of 2 a.m. incident calls. I still make the same kind of choices inside ftryos and pipeline-sdk.

Consistent error shapes are not a nice-to-have. They are a force multiplier for every engineer who touches the frontend and every support interaction where a user reports a problem. Standardize early and enforce it in middleware, not in code reviews.