fetch returns a Response, not the object your business rule expects. A 404
also does not reject the Promise by itself. If a helper turns everything into
T with as User, the compiler stops showing the boundary where external data
has not been checked yet.
The small pattern is a discriminated union: { ok: true, data } for success and
{ ok: false, error } for failure. The error can distinguish an HTTP status,
request failure, invalid JSON, and data that failed validation. Each caller must
then choose a path before it uses the value.
This complements validating external JSON before using it in TypeScript. That article focuses on runtime validation; this one models the complete result so callers do not hide failures inside generic exceptions.
Short answer
fetchcan fulfill with a404or500; checkresponse.okbefore reading application data.response.json()is still asynchronous and can fail after the headers arrive.- Use
Result<T, E>with a literal discriminator such asok: trueorok: false.- Use
unknownat the boundary and a runtime validator before claiming the result isT.
Why is fetch not a business result?
The fetch Promise represents a network response, not the success of your
application operation. MDN's fetch documentation explains that the Promise fulfills with a Response and does not reject merely because the server returned 404 or 504. The ok property indicates a status from 200 through 299.
There is a second stage. response.json() reads the body and returns another
Promise. The server can send valid headers and then deliver malformed JSON, or a
shape different from the one your code expects. A helper that only catches the
fetch call does not represent every path the caller needs to handle.
Response also has no knowledge of User, Order, or any other application
contract. A TypeScript interface describes what you declared, but it does not
inspect bytes received from the network. The same difference between declaration
and proof appears when you type data added by Express middleware.
How do you model success and failure with a discriminated union?
A discriminated union uses one shared property with different literal values for
each variant. The TypeScript narrowing handbook shows that comparing this discriminator reduces the union to the compatible member. For a response, ok: true provides data; ok: false provides error.
The generic type below keeps the success value and failure value independent. The
example is illustrative and should be checked with tsc --strict in the project
that adopts it:
type Ok<T> = {
ok: true;
data: T;
};
type Err<E> = {
ok: false;
error: E;
};
type Result<T, E> = Ok<T> | Err<E>;
function showUser(result: Result<User, FetchError>) {
if (result.ok) {
return result.data.name;
}
return `request failed: ${result.error.kind}`;
}
Do not use ok: boolean in both variants. If the discriminator is only a
boolean, the compiler loses the relationship between data and error. Do
not put both fields on one object as optional properties either. That shape
allows states such as { ok: true, error: ... } and requires more manual checks.
How do you separate HTTP, request, and JSON failures?
Model failures that the caller can handle differently. An HTTP status might become a message for a user, a network failure might allow a later retry, and invalid JSON might require an alert to the provider. An error union preserves that difference without making every call inspect a message string.
type FetchError =
| { kind: "http"; status: number }
| { kind: "request"; cause: unknown }
| { kind: "invalid-json"; cause: unknown }
| { kind: "invalid-data"; message: string };
type Validator<T> = (value: unknown) => value is T;
export async function getJson<T>(
input: RequestInfo | URL,
isT: Validator<T>,
): Promise<Result<T, FetchError>> {
let response: Response;
try {
response = await fetch(input);
} catch (cause) {
return { ok: false, error: { kind: "request", cause } };
}
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
return { ok: false, error: { kind: "http", status: response.status } };
}
let value: unknown;
try {
value = await response.json();
} catch (cause) {
return { ok: false, error: { kind: "invalid-json", cause } };
}
if (!isT(value)) {
return {
ok: false,
error: { kind: "invalid-data", message: "response shape is not valid" },
};
}
return { ok: true, data: value };
}
The return value of response.json() is treated as unknown by choice. The
validator is the runtime proof that turns it into T. If the application uses
Zod, Valibot, JSON Schema, or a generated client, replace only isT. Do not
remove the boundary because the compiler cannot read the response that arrived
from the network.
The body.cancel() on the HTTP path is also deliberate. If you will not consume
the body of a response you already know is invalid, cancel it or consume it
according to the client and protocol you use. This does not turn an HTTP status
into an exception automatically. It only avoids leaving a response without a
consumer.
How do you consume the result without casts?
The caller should narrow first and access the property second. A switch on
kind makes error paths visible and allows an exhaustive check. This example is
also illustrative:
function assertNever(value: never): never {
throw new Error(`unhandled result: ${String(value)}`);
}
function describe(result: Result<User, FetchError>): string {
if (result.ok) {
return `Hello, ${result.data.name}`;
}
switch (result.error.kind) {
case "http":
return `upstream returned ${result.error.status}`;
case "request":
return "the request could not be completed";
case "invalid-json":
return "the upstream response was not valid JSON";
case "invalid-data":
return result.error.message;
default:
return assertNever(result.error);
}
}
If you add a new FetchError variant, assertNever makes tsc point to this
switch until the consumer chooses a behavior. This does not promise that the
system can never fail. It makes the set of decisions known to the code that
compiles.
Do not destructure data and error before checking ok just to shorten the
code. In different versions and shapes, the narrowing relationship becomes less
obvious. Keep the object until you compare the discriminator, then access the
narrowed variant.
How do you verify that the type protects the boundary?
Start with the compiler. Run npx tsc --noEmit with the same options as CI and
confirm that result.data fails in the ok: false branch, that result.error
fails in the success branch, and that the switch reports a new variant. If the
project has no TypeScript setup yet, treat the snippets as illustrative until
you create a small fixture with strict: true.
Then test behavior in four cases: a 2xx response with a valid shape, an HTTP error status, a body that is not JSON, and JSON rejected by the validator. Add a connection failure with a local endpoint or a transport mock. The test should check the variant and relevant fields, not merely that a Promise finished.
For the test layer, use the same idea as testing JavaScript retry logic without waiting: control the external dependency and check the observable transition. Do not turn the test into proof that TypeScript performed runtime validation. Types disappear when the code becomes JavaScript.
What does this pattern not solve?
A Result union does not validate JSON by itself, choose a timeout, make a POST
idempotent, retry a call safely, or guarantee that a server reversed an external
effect. It organizes the return contract. Retry, cancellation, telemetry, and
idempotency remain decisions for the layer that understands the operation.
You also do not need to replace every exception. Use a result when an expected failure belongs in the contract the caller must handle. Reserve exceptions for broken invariants or failures the current layer truly cannot represent. If a library already provides a result type, adapt it at the boundary instead of creating a second convention in every call.
Frequently asked questions
Does fetch reject when it receives a 404?
No. The Promise normally fulfills with a Response, and the caller must inspect
response.ok or response.status. MDN's Fetch guide separates HTTP status from failures such as a network error or invalid URL. A helper can turn the status into an http variant without throwing a generic exception.
Does a TypeScript interface validate received JSON?
No. An interface describes what the program expects, but it does not inspect a
runtime value. Read the body as unknown and use a type guard or schema library
to prove its shape before returning T. The article on validating external JSON shows this boundary with a nested object.
Does Result<T, E> replace try/catch everywhere?
No. It helps when success and failure belong in the contract the caller must handle. An unexpected error can still be thrown and caught by an infrastructure layer. The benefit comes from choosing, per function, which expected failures become data and which remain exceptions.
Conclusion
Typing a fetch response means separating what the network delivered from what
the application can trust. Check the status first. Then read the body, validate
its shape, and only then produce T. A discriminated union carries that contract
to the caller without hiding failures in any, as, or unstructured messages.
The pattern stays small when each layer has one job: fetch transports, the
validator proves the shape, and Result<T, E> makes decisions explicit. Retry,
cancellation, and observability can then evolve without rewriting how every call
interprets success and failure.