Code review · TypeScript
Review a TypeScript API Client
Medium Code review
A front-end team wrote this small API client for a checkout page.
Review it: list the problems, what could go wrong for users, and how to fix each one.
The code to review
There are 6 issues to find.
const BASE = "https://api.example.com";
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem("token");
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch(BASE + path, {
...options,
headers: { Authorization: `Bearer ${token}` },
});
return (await res.json()) as T;
} catch (e) {
// network error: try again
}
}
return {} as T;
}
export const pay = (cartId: string, cardToken: string) =>
request<{ orderId: string }>("/payments", {
method: "POST",
body: JSON.stringify({ cartId, cardToken }),
});
Write your review
Loading the editor… (JavaScript is required to run code in your browser.)
The issues and how to fix them
Show the issues
1. Retrying a non-idempotent payment
Critical, line 5-15, 18-22
A POST to /payments is retried whenever the network fails, including when the request reached the server and only the response was lost. The customer can be charged several times.
Fix: Do not retry non-idempotent requests blindly. Send an Idempotency-Key header (a UUID per checkout attempt) so the server can deduplicate, and only then allow retries.
2. HTTP errors are treated as success
Major, line 11
fetch only rejects on network errors. A 400, 401 or 500 response is parsed and returned as if it were T, so the UI may show an order ID of undefined.
Fix: Check res.ok and throw a typed error with the status and body.
3. Headers from the caller are dropped
Major, line 8-9
headers: { Authorization } replaces options.headers, so Content-Type: application/json is never sent and the server may reject or misparse the body.
Fix: Merge headers: new Headers(options.headers), then set Authorization and a default Content-Type.
4. Failure returns an empty object typed as T
Major, line 16
After five failures the function returns {} cast to T: the type system now lies, and callers cannot tell that the request failed.
Fix: Throw after the final attempt (and type the error), so callers must handle it.
5. Unvalidated JSON cast to T
Major, line 11
as T is a compile-time assertion only; the response is never checked at runtime.
Fix: Validate at the boundary with a schema (Zod, Valibot) for responses the UI depends on.
6. Token in localStorage, no timeout, no backoff
Minor, line 4-5
Any XSS can read localStorage. Retries happen immediately with no delay, and there is no timeout.
Fix: Prefer an httpOnly, SameSite session cookie; use AbortSignal.timeout(10_000); back off between retries (for safe methods only).
The payment retry is the finding that matters most: interviewers want to hear the word idempotency and how an idempotency key makes retries safe. After that: fetch does not throw on HTTP errors, and TypeScript casts are not validation.
Fixed version
const BASE = "https://api.example.com";
export class ApiError extends Error {
constructor(public status: number, public body: unknown) {
super(`API error ${status}`);
}
}
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function request<T>(path: string, options: RequestInit = {}, parse: (x: unknown) => T): Promise<T> {
const method = (options.method ?? "GET").toUpperCase();
const headers = new Headers(options.headers);
if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const attempts = SAFE.has(method) || headers.has("Idempotency-Key") ? 3 : 1;
for (let attempt = 1; ; attempt++) {
try {
const res = await fetch(BASE + path, {
...options, headers, credentials: "include", signal: AbortSignal.timeout(10_000),
});
const body = await res.json().catch(() => null);
if (!res.ok) throw new ApiError(res.status, body);
return parse(body); // runtime validation, e.g. a Zod schema's .parse
} catch (e) {
const retryable = !(e instanceof ApiError) || e.status >= 500;
if (!retryable || attempt >= attempts) throw e;
await sleep(2 ** attempt * 200);
}
}
}
export const pay = (cartId: string, cardToken: string, attemptId: string) =>
request("/payments", {
method: "POST",
headers: { "Idempotency-Key": attemptId },
body: JSON.stringify({ cartId, cardToken }),
}, PaymentResponse.parse);
Follow-up questions
- How does the server implement idempotency keys?
- Which HTTP methods are idempotent by definition?
More Code Clinic: Code Review problems
- Review a Laravel Orders Controller · Medium
- Review an Express User Search Endpoint · Easy
- Review a Payment Webhook Handler · Medium
- Review a Sign-Up Function · Medium
- Review a CSV Export Endpoint · Medium
- Review a React Search Component · Medium