export type ErrorCode =
| 'VALIDATION'
| 'UNAUTHENTICATED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'RATE_LIMITED'
| 'INTERNAL';
export interface ApiErrorBody {
error: {
code: ErrorCode;
message: string;
details?: Record<string, string[]>;
requestId: string;
};
}
const STATUS_BY_CODE: Record<ErrorCode, number> = {
VALIDATION: 422,
UNAUTHENTICATED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
RATE_LIMITED: 429,
INTERNAL: 500,
};
export class ApiError extends Error {
readonly code: ErrorCode;
readonly status: number;
readonly details?: Record<string, string[]>;
constructor(code: ErrorCode, message: string, details?: Record<string, string[]>) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = STATUS_BY_CODE[code];
this.details = details;
}
}
import { ErrorRequestHandler, Request } from 'express';
import { ZodError } from 'zod';
import { ApiError, ApiErrorBody } from './errors';
function requestIdOf(req: Request): string {
return (req.headers['x-request-id'] as string) || 'unknown';
}
export const errorMiddleware: ErrorRequestHandler = (err, req, res, _next) => {
const requestId = requestIdOf(req);
if (err instanceof ApiError) {
const body: ApiErrorBody = {
error: { code: err.code, message: err.message, details: err.details, requestId },
};
return res.status(err.status).json(body);
}
if (err instanceof ZodError) {
const body: ApiErrorBody = {
error: {
code: 'VALIDATION',
message: 'Request validation failed',
details: err.flatten().fieldErrors as Record<string, string[]>,
requestId,
},
};
return res.status(422).json(body);
}
// Unexpected: log the real cause, expose nothing.
console.error(`[${requestId}] unhandled error`, err);
const body: ApiErrorBody = {
error: { code: 'INTERNAL', message: 'Something went wrong', requestId },
};
return res.status(500).json(body);
};
import { ApiErrorBody, ErrorCode } from './errors';
export class ClientApiError extends Error {
readonly code: ErrorCode;
readonly status: number;
readonly details?: Record<string, string[]>;
readonly requestId: string;
constructor(status: number, body: ApiErrorBody['error']) {
super(body.message);
this.name = 'ClientApiError';
this.code = body.code;
this.status = status;
this.details = body.details;
this.requestId = body.requestId;
}
}
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(path, {
...init,
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
});
} catch {
throw new ClientApiError(0, { code: 'INTERNAL', message: 'Network error', requestId: 'client' });
}
if (res.ok) {
return (await res.json()) as T;
}
let body: ApiErrorBody['error'];
try {
body = ((await res.json()) as ApiErrorBody).error;
} catch {
body = { code: 'INTERNAL', message: 'Unexpected response', requestId: 'client' };
}
throw new ClientApiError(res.status, body);
}
import { useState, useCallback } from 'react';
import { request, ClientApiError } from './apiClient';
interface LoginResult {
token: string;
}
export function useLogin() {
const [banner, setBanner] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
const [loading, setLoading] = useState(false);
const login = useCallback(async (email: string, password: string) => {
setLoading(true);
setBanner(null);
setFieldErrors({});
try {
const { token } = await request<LoginResult>('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
return token;
} catch (err) {
if (err instanceof ClientApiError && err.code === 'VALIDATION') {
setFieldErrors(err.details ?? {});
} else if (err instanceof ClientApiError && err.code === 'UNAUTHENTICATED') {
setBanner('Invalid email or password');
} else {
const id = err instanceof ClientApiError ? err.requestId : 'unknown';
setBanner(`Something went wrong (ref: ${id})`);
}
return null;
} finally {
setLoading(false);
}
}, []);
return { login, banner, fieldErrors, loading };
}
A stable error shape is one of the highest-leverage contracts in a web app: the frontend needs to distinguish a validation problem from an auth failure from an unexpected crash, and it needs to do so without string-matching human-readable messages. This snippet defines that contract once and enforces it on both ends of the wire.
In errors.ts, the shape is codified as ApiErrorBody: a discriminated envelope with a machine-readable code, a human message, an optional details map for per-field validation errors, and a requestId for correlating client reports with server logs. The ApiError class carries an HTTP status alongside the code, and the ErrorCode union is the single source of truth both sides import. Making code a closed union means the compiler flags any handler that forgets a case.
In errorMiddleware.ts, an Express error handler normalizes everything into that envelope. Known ApiError instances pass through with their status; ZodError instances become a VALIDATION response with details built from flatten().fieldErrors, so the client gets structured field messages rather than a wall of text. Anything else is treated as an unexpected INTERNAL error — the real cause is logged with the requestId but never leaked to the client. This is the key trade-off: the response is deliberately lossy so internal details stay private while remaining debuggable server-side. Note the four-argument signature, which is how Express recognizes error middleware.
In apiClient.ts, the browser side mirrors the contract. request inspects res.ok, parses the JSON envelope, and rethrows a typed ClientApiError so callers can branch on err.code instead of err.status. A network failure or non-JSON body is coerced into a synthetic INTERNAL error so callers never have to handle two failure vocabularies.
In useLogin.ts, a React hook consumes the result: on a VALIDATION code it surfaces details as per-field messages, and everything else collapses to a single banner. Because ErrorCode is shared, adding a new code forces a compile error until the UI handles it, which is exactly the safety net an evolving API wants. The pattern shines wherever multiple clients depend on predictable failures; the cost is the discipline of always throwing through the envelope.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
Share this code
Here's the card — post it anywhere.