A small fetch() wrapper can give TypeScript callers a consistent way to handle successful responses, HTTP failures, invalid JSON, and cancellation. The important limitation: a generic such as fetchJson<User>() does not verify that the server actually returned a User. It only tells the compiler to treat the parsed value as one.
This tutorial builds a JSON-focused wrapper that returns a discriminated result instead of throwing for expected request failures. It also shows how to pass an AbortSignal and when runtime validation is necessary.
Key Takeaways
- Use a discriminated union so callers must handle success and failure separately.
- Treat HTTP errors, request failures, body-read failures, invalid JSON, and aborts as distinct outcomes.
- Accept
RequestInitso callers can supply anAbortSignaland other standard fetch options. fetchJson<User>()is a compile-time assertion, not runtime validation. Validate untrusted JSON before using it asUser.- This wrapper handles JSON responses; a
204 No Contentresponse is not valid JSON and should use a different helper or an explicit empty-body case.
Define the result type
The wrapper will return either parsed data or a structured error. Checking result.ok narrows the union, so TypeScript can distinguish the two cases.
type FetchJsonError =
| { kind: "aborted" }
| { kind: "request"; message: string }
| { kind: "http"; status: number; statusText: string }
| { kind: "body-read"; message: string }
| { kind: "invalid-json"; message: string };
type FetchJsonResult<T> =
| { ok: true; status: number; headers: Headers; data: T }
| { ok: false; error: FetchJsonError };
The error types describe different stages. A request error means fetch() itself rejected. An http error means a response arrived but its status was outside the successful range. body-read and invalid-json distinguish failure to read the response body from failure to parse its contents.
Implement the JSON wrapper
This implementation uses the standard Fetch API types. It accepts the same input and options as fetch(), including signal, and does not throw for the failure cases represented in its result type.
function isAbortError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "AbortError"
);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function fetchJson<T>(
input: RequestInfo | URL,
init: RequestInit = {},
): Promise<FetchJsonResult<T>> {
let response: Response;
try {
response = await fetch(input, init);
} catch (error: unknown) {
if (init.signal?.aborted || isAbortError(error)) {
return { ok: false, error: { kind: "aborted" } };
}
return {
ok: false,
error: { kind: "request", message: errorMessage(error) },
};
}
if (!response.ok) {
return {
ok: false,
error: {
kind: "http",
status: response.status,
statusText: response.statusText,
},
};
}
let text: string;
try {
text = await response.text();
} catch (error: unknown) {
if (init.signal?.aborted || isAbortError(error)) {
return { ok: false, error: { kind: "aborted" } };
}
return {
ok: false,
error: { kind: "body-read", message: errorMessage(error) },
};
}
let data: unknown;
try {
data = JSON.parse(text);
} catch (error: unknown) {
return {
ok: false,
error: { kind: "invalid-json", message: errorMessage(error) },
};
}
// This assertion is not runtime validation. See the next section.
return {
ok: true,
status: response.status,
headers: response.headers,
data: data as T,
};
}
The helper checks response.ok before parsing the body. For non-2xx responses it returns the status metadata without consuming or returning the error body. If your application needs structured API error payloads, add a separate, explicitly typed parsing path for those responses.
Because this helper calls JSON.parse, successful responses must contain valid JSON. An empty body, including a typical 204 No Content response, becomes an invalid-json result. Use a separate text or no-content helper when an endpoint does not return JSON.
Use the wrapper and handle each result
Pass the expected type as a generic argument, then branch on ok:
interface User {
id: string;
name: string;
}
const result = await fetchJson<User>("/api/users/42");
if (!result.ok) {
switch (result.error.kind) {
case "http":
console.error(`Request failed: ${result.error.status} ${result.error.statusText}`);
break;
case "aborted":
console.info("Request was cancelled");
break;
default:
console.error(result.error.message);
}
} else {
console.log(result.data.name);
}
Inside the else branch, TypeScript knows the result is successful. That narrows the wrapper's return type, but it does not prove the server's response has the shape of User.
Understand what the generic does—and does not do
This call:
const result = await fetchJson<User>("/api/users/42");
sets the compile-time type of result.data. The response body still arrives as text, and JSON.parse() does not check it against the User interface. If the server returns { "name": 42 }, TypeScript will not catch that mismatch at runtime.
For untrusted data, request unknown and validate it before use. A small type guard can check the fields this application needs:
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) {
return false;
}
return (
"id" in value &&
typeof value.id === "string" &&
"name" in value &&
typeof value.name === "string"
);
}
const result = await fetchJson<unknown>("/api/users/42");
if (result.ok) {
if (!isUser(result.data)) {
throw new Error("The server returned an unexpected user response");
}
console.log(result.data.name); // Narrowed to User.
}
For larger schemas, a runtime validation library can reduce repetitive checks. Whichever approach you choose, validate data at the boundary before treating it as trusted application data.
Pass an AbortSignal
fetchJson() accepts RequestInit, so callers can supply an AbortSignal without changing the wrapper API. This example cancels a request after five seconds and clears the timer when the request settles:
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5_000);
try {
const result = await fetchJson<User>("/api/users/42", {
signal: controller.signal,
});
if (!result.ok && result.error.kind === "aborted") {
console.info("The request timed out or was cancelled");
}
} finally {
clearTimeout(timeoutId);
}
This wrapper reports cancellation as aborted; it does not distinguish a user cancellation from a timeout. If the UI needs different behavior, track the cancellation reason separately or return more context from your calling code. Aborting can happen while the response body is being read as well as while the initial request is in progress.
Practical notes
- CORS and network failures: In a browser, a CORS failure commonly makes
fetch()reject instead of providing a readable HTTP response. The wrapper can report a request error, but it cannot expose a blocked response's status or fix the server's CORS configuration. - Runtime support and TypeScript libraries: Use an environment with a global Fetch API. The compiler also needs compatible Fetch type definitions; browser projects commonly get these from the DOM library, while server projects depend on their runtime and type setup.
- Status checking: To check a URL's response status separately while diagnosing an endpoint, you can use the HTTP status checker. A status check does not verify your application's response schema or replace handling errors in code.
- Request configuration: Because the helper forwards
RequestInit, callers can still provide headers, credentials, methods, and other standard options. Keep authentication and sensitive request data under the application's normal security controls.
Common Mistakes
- Assuming a generic validates JSON. It affects TypeScript's compile-time view only. Parse into
unknownand validate when the response is not already trusted and guaranteed by your system. - Treating every failure as an HTTP error. Network rejection, HTTP status failure, body-read failure, invalid JSON, and abort are different cases and may call for different UI or retry behavior.
- Assuming every success has a JSON body.
response.okincludes successful statuses such as204; an empty body cannot be parsed as JSON. - Retrying every error automatically. An invalid response shape or client-side abort is not necessarily fixed by retrying. Make retry decisions based on the failure kind and the endpoint's semantics.
- Ignoring response size and content type. This helper reads the full body as text and attempts JSON parsing; it does not enforce a size limit or check
Content-Type. For endpoints with untrusted or potentially large responses, add appropriate limits and response checks.
Best Practices
- Keep the wrapper's contract explicit: this version handles JSON responses and returns expected failures as values.
- Validate external JSON at runtime when correctness or security depends on its shape.
- Preserve the distinction between HTTP errors and failures that prevented a usable response.
- Pass cancellation through from the caller instead of creating an unobservable controller inside the wrapper.
- Add tests for successful JSON, non-success status, malformed JSON, body-read failure where practical, and cancellation.
- If callers need text, blobs, streams, or empty responses, provide helpers that reflect those response types rather than forcing all endpoints through a JSON parser.
Documentation note
Live documentation retrieval was not available while preparing this draft, so current TypeScript and Fetch API documentation could not be checked directly. The examples use standard TypeScript and Fetch API patterns and make no version-specific compatibility claims; confirm the relevant type definitions for your runtime and compiler configuration.
Conclusion
A useful TypeScript fetch() wrapper makes failure modes explicit and passes cancellation through to the underlying request. Keep one boundary clear: a generic response type is an assertion, so use runtime validation when you need to know that parsed JSON really matches your application type.
