Safe Fetch Wrapper
Date: 2026-09-18
Status: Draft for discussion
Discussion: GitHub issue #363
Summary
Add a small, opt-in WebAPI.SafeFetch module alongside the existing raw WebAPI.Fetch and
WebAPI.Response bindings.
The raw bindings remain the canonical 1:1 representation of the browser APIs. The wrapper provides
an application-friendly path where:
- a rejected
fetch() promise becomes an Error value;
- a fulfilled response with
response.ok === false becomes a distinct Error value;
- response body reader rejections become
Error values; and
- successful body reads retain the original
Response.t for status, headers, and other metadata.
The proposed first version has no production dependencies and does not attempt to decode JSON into
application domain types.
Context
The native Fetch API has several failure channels that are easy to accidentally conflate:
fetch() rejects because the request could not produce a response. This includes aborts,
malformed requests, permissions failures, and network failures.
fetch() fulfills with a response whose HTTP status is not successful. Native Fetch deliberately
does not reject for statuses such as 404 or 500.
- A response body reader rejects. A body may be aborted, disturbed, locked, incorrectly encoded, or,
for json(), syntactically invalid.
- Parsed JSON does not match the application's expected domain shape. This is application decoding,
not a Fetch failure.
The current bindings accurately expose the browser behavior:
let response: Response.t = await Fetch.fetch(url)
let json: JSON.t = await response->Response.json
That fidelity should not change. An additional wrapper can make the common application workflow
explicit without weakening or obscuring the raw API.
References:
Goals
- Preserve all existing raw Fetch bindings and their current behavior.
- Make the result-based API opt-in and easy to discover.
- Ensure wrapper promises fulfill with
result values for expected Fetch and body-reading failures.
- Keep rejected Fetch operations separate from non-successful HTTP responses.
- Preserve the native
Response.t on HTTP and body-reading outcomes.
- Cover all body readers currently exposed by
Response.
- Require no decoder, validation, promise, or effect dependency.
- Keep the wrapper in the existing
WebAPI.Fetch feature.
Non-Goals
- Replacing, deprecating, renaming, or changing
Fetch.fetch, Fetch.fetchWithRequest, or the
Response body reader bindings.
- Providing a general HTTP client with base URLs, authentication, retries, caching, timeouts,
interceptors, or request deduplication.
- Treating non-2xx responses as transport failures.
- Guessing whether a rejected request was an abort, CORS failure, offline state, invalid URL, or
another browser failure when the platform does not expose a stable distinction.
- Decoding
JSON.t into application-specific domain types.
- Introducing a broad safe wrapper layer for every Web API.
Proposed Module
Add src/fetch/SafeFetch.res and expose SafeFetch from the existing Fetch feature:
WebAPI.Fetch.fetch(url) // raw, unchanged
WebAPI.Response.json(response) // raw, unchanged
WebAPI.SafeFetch.fetch(url) // opt-in result API
WebAPI.SafeFetch.json(response)
SafeFetch is preferred over WebAPI.Stdlib because it names the behavior and remains scoped to one
Web API area. Stdlib would imply a broad alternative standard library, create an unclear ownership
boundary, and compete conceptually with ReScript's own standard library.
In this proposal, "safe" has a narrow contract: expected native Fetch and response body-reader
failures are returned as typed values rather than escaping through synchronous exceptions or
rejected promises. It does not imply application JSON validation, retries, timeouts, or protection
from programmer defects. Keeping the name on a module lets each stage expose a precise function and
error type instead of putting all behavior behind one monolithic safeFetch function.
Proposed Types
The following is an API sketch rather than final implementation syntax:
type httpError = {
response: Response.t,
}
type fetchError =
| FetchRejected(exn)
| ResponseNotOk(httpError)
type readError = {
response: Response.t,
cause: exn,
}
type response<'body> = {
response: Response.t,
body: 'body,
}
The error names describe observable platform behavior rather than overclaiming a cause:
FetchRejected means the native Fetch promise rejected. It is intentionally not named
NetworkError, because Fetch can reject for non-network reasons.
ResponseNotOk means Fetch produced a response and its ok property was false.
readError means a native body reader rejected and retains both the response and rejection cause.
The original rejection is retained as exn rather than normalized into a string. This preserves
the value for inspection and avoids discarding useful error identity, message, or stack information.
Proposed Functions
let checkOk: Response.t => result<Response.t, httpError>
let fetch: (
string,
~init: Request.requestInit=?,
) => promise<result<Response.t, fetchError>>
let fetchWithRequest: (
Request.t,
~init: Request.requestInit=?,
) => promise<result<Response.t, fetchError>>
let arrayBuffer: Response.t => promise<result<response<ArrayBuffer.t>, readError>>
let blob: Response.t => promise<result<response<Blob.t>, readError>>
let bytes: Response.t => promise<result<response<array<int>>, readError>>
let formData: Response.t => promise<result<response<FormData.t>, readError>>
let json: Response.t => promise<result<response<JSON.t>, readError>>
let text: Response.t => promise<result<response<string>, readError>>
checkOk
checkOk is a pure status classification helper:
let checkOk = response =>
if response.ok {
Ok(response)
} else {
Error({response})
}
It is useful with an existing Response.t, including one obtained from the raw API. It follows the
platform's definition of Response.ok, including classifying opaque responses with status 0 as
not OK.
fetch and fetchWithRequest
These functions catch synchronous exceptions and promise rejections from their corresponding raw
bindings. A fulfilled response is then passed through checkOk.
The returned promise should fulfill with exactly one of these outcomes:
Ok(response) response.ok is true
Error(FetchRejected(cause)) native Fetch rejected
Error(ResponseNotOk({response})) native Fetch fulfilled, response.ok is false
Keeping the non-OK Response.t is important because APIs commonly return useful error headers and
bodies.
Body Readers
Each body reader delegates to its matching raw Response function and catches its rejection. It
does not check response.ok; status classification and body consumption remain separate,
composable operations.
On success, the result contains the parsed body and original response:
The retained response has already had its body consumed. Consumers must not assume it can be read a
second time. Code that needs multiple reads must clone the response before consuming it, following
the native Fetch contract.
Usage
Common JSON Request
switch await SafeFetch.fetch("/api/profile") {
| Error(FetchRejected(cause)) => handleUnavailable(cause)
| Error(ResponseNotOk({response})) => handleHttpError(response.status)
| Ok(response) =>
switch await response->SafeFetch.json {
| Error({response, cause}) => handleInvalidBody(response.status, cause)
| Ok({response, body}) => handleJson(response.headers, body)
}
}
Reading an Error Body
Because a non-OK response is preserved, callers can still inspect its body safely:
switch await SafeFetch.fetch("/api/profile") {
| Error(ResponseNotOk({response})) =>
switch await response->SafeFetch.text {
| Ok({body}) => Console.error(body)
| Error(_) => Console.error(`Request failed with ${Int.toString(response.status)}`)
}
| Error(FetchRejected(_)) => Console.error("The request did not produce a response")
| Ok(_) => ()
}
Application JSON Decoding
SafeFetch.json should return JSON.t, matching the raw binding. Applications can then use a
total decoder whose own failures are typed for that domain:
type profileDecodeError =
| ExpectedProfileObject
| MissingName
let decodeProfile = (json: JSON.t): result<profile, profileDecodeError> => {
// Application-owned validation.
}
This keeps malformed JSON text, which causes the native body reader to reject, separate from valid
JSON that has the wrong application shape.
Why Not a One-Shot fetchJson Yet?
A one-shot helper initially looks attractive:
let fetchJson: string => promise<result<response<JSON.t>, requestError>>
It quickly produces a larger API matrix: URL versus Request.t, each body representation, optional
application decoders, and a combined error type for Fetch, HTTP status, body reading, and decoding.
The proposed staged functions cover those workflows with a smaller surface and retain each failure
boundary.
After the primitive API has real usage, one-shot helpers can be considered using evidence from
common call sites. They can be added without breaking this design.
Error Semantics
| Stage |
Raw behavior |
Wrapper behavior |
Preserved context |
| Request |
Fetch promise rejects |
Error(FetchRejected(cause)) |
Original rejection |
| HTTP status |
Fetch fulfills with ok === false |
Error(ResponseNotOk({response})) |
Full response |
| Body read |
Reader promise rejects |
Error({response, cause}) |
Response and rejection |
| Domain decode |
Application decoder returns Error |
Unchanged application error |
Application-defined |
The wrapper should not convert arbitrary failures into messages or invent browser-independent error
categories. Consumers that care about abort handling can inspect the retained cause, while consumers
that do not can handle all request rejections uniformly.
Relationship to the Raw Bindings
This is an additive convenience layer, not a new foundation for the package:
- Raw modules continue to model MDN and the platform 1:1.
- Existing documentation and examples remain valid.
- The wrapper is handwritten application ergonomics and should be documented as such.
- New raw Fetch API coverage belongs in
Fetch, Request, Response, and related binding modules.
- New result-based behavior belongs in
SafeFetch only when it is broadly useful and composes
with the raw values.
Packaging and Feature Gating
SafeFetch.res should live in src/fetch, be added to that source group's public module list, and
remain available under the existing WebAPI.Fetch feature. It should not introduce a new feature or
dependency.
Implementation Shape
The implementation should use a small internal body-reader helper so every public reader shares the
same rejection and response-preservation behavior:
let read = async (response, readBody) => {
try {
Ok({response, body: await readBody(response)})
} catch {
| cause => Error({response, cause})
}
}
Public functions then supply the existing raw reader, for example Response.json or
Response.text. The exact exception pattern should be verified against the ReScript version used by
the repository during implementation.
Testing
Runtime tests should cover:
- an OK response becomes
Ok(response);
- a non-OK response becomes
ResponseNotOk and preserves that exact response;
- a rejected Fetch promise becomes
FetchRejected and the wrapper promise fulfills;
- valid JSON and text bodies return
Ok({response, body});
- malformed JSON becomes
readError rather than a rejected wrapper promise;
- a previously consumed or locked body becomes
readError;
- a non-OK response body can still be read with the safe body helpers; and
- each remaining body reader compiles with the expected public type.
Feature checks should verify that SafeFetch is public with WebAPI.Fetch and absent when the
Fetch feature is disabled.
Documentation
If accepted, add a short "Result-based workflows" section to the Fetch API documentation that:
- identifies
Fetch and Response as the raw bindings;
- introduces
SafeFetch as opt-in convenience;
- explains the difference between rejection, non-OK status, and body-reading failure; and
- includes one JSON example without implying that
JSON.t is application validation.
Alternatives Considered
Add Safe Functions to Fetch and Response
This is discoverable, but it mixes a convenience policy into a module whose current purpose is raw
platform bindings. The name also leaves body readers without a natural home.
Add fetchResult to Fetch and result readers to Response
This keeps functions near their raw equivalents but makes the raw modules a mixture of 1:1 bindings
and opinionated wrappers. A separate module makes the opt-in boundary visible at every call site.
Add WebAPI.Stdlib
This creates a large, vague namespace before there is evidence for a package-wide wrapper design.
It also makes feature ownership and gating less obvious. A Fetch-specific module can later inform a
broader pattern if other Web APIs develop similar wrappers.
Return only a string error
This is easy to log but destroys response bodies, headers, rejection identity, and structured error
handling. It would also force the library to choose messages that applications may need to parse.
Return only the decoded body
This is compact but discards status, headers, URL, redirection state, and other response metadata
that is commonly needed after a successful request.
Add a JSON decoder dependency
Decoder choice belongs to applications and higher-level HTTP libraries. Keeping JSON.t avoids a
production dependency and works with handwritten decoders or any decoding library.
Open Questions
- Should
SafeFetch.fetch check response.ok by default, as proposed, or should rejected Fetch
and HTTP status be two explicitly composed functions?
- Should successful body readers return
response<'body> as proposed, or only the body value?
- Should the first version include all six existing body readers, or begin with
json and text?
- Is retaining
exn the desired public error representation, or should the wrapper expose a small
normalized error record while retaining the original value?
- Should a later iteration add one-shot helpers such as
fetchJson, after usage establishes the
desired combined error type?
Recommendation
Proceed with WebAPI.SafeFetch as a focused experiment inside the Fetch feature. Start with
checkOk, the two Fetch entry points, and all existing response body readers. Preserve native values
in every error and success case, add no dependencies, and wait for real call-site evidence before
adding one-shot request-and-decode helpers or a broader WebAPI.Stdlib concept.
Safe Fetch Wrapper
Date: 2026-09-18
Status: Draft for discussion
Discussion: GitHub issue #363
Summary
Add a small, opt-in
WebAPI.SafeFetchmodule alongside the existing rawWebAPI.FetchandWebAPI.Responsebindings.The raw bindings remain the canonical 1:1 representation of the browser APIs. The wrapper provides
an application-friendly path where:
fetch()promise becomes anErrorvalue;response.ok === falsebecomes a distinctErrorvalue;Errorvalues; andResponse.tfor status, headers, and other metadata.The proposed first version has no production dependencies and does not attempt to decode JSON into
application domain types.
Context
The native Fetch API has several failure channels that are easy to accidentally conflate:
fetch()rejects because the request could not produce a response. This includes aborts,malformed requests, permissions failures, and network failures.
fetch()fulfills with a response whose HTTP status is not successful. Native Fetch deliberatelydoes not reject for statuses such as 404 or 500.
for
json(), syntactically invalid.not a Fetch failure.
The current bindings accurately expose the browser behavior:
That fidelity should not change. An additional wrapper can make the common application workflow
explicit without weakening or obscuring the raw API.
References:
fetch()Response.okResponse.json()Goals
resultvalues for expected Fetch and body-reading failures.Response.ton HTTP and body-reading outcomes.Response.WebAPI.Fetchfeature.Non-Goals
Fetch.fetch,Fetch.fetchWithRequest, or theResponsebody reader bindings.interceptors, or request deduplication.
another browser failure when the platform does not expose a stable distinction.
JSON.tinto application-specific domain types.Proposed Module
Add
src/fetch/SafeFetch.resand exposeSafeFetchfrom the existing Fetch feature:SafeFetchis preferred overWebAPI.Stdlibbecause it names the behavior and remains scoped to oneWeb API area.
Stdlibwould imply a broad alternative standard library, create an unclear ownershipboundary, and compete conceptually with ReScript's own standard library.
In this proposal, "safe" has a narrow contract: expected native Fetch and response body-reader
failures are returned as typed values rather than escaping through synchronous exceptions or
rejected promises. It does not imply application JSON validation, retries, timeouts, or protection
from programmer defects. Keeping the name on a module lets each stage expose a precise function and
error type instead of putting all behavior behind one monolithic
safeFetchfunction.Proposed Types
The following is an API sketch rather than final implementation syntax:
The error names describe observable platform behavior rather than overclaiming a cause:
FetchRejectedmeans the native Fetch promise rejected. It is intentionally not namedNetworkError, because Fetch can reject for non-network reasons.ResponseNotOkmeans Fetch produced a response and itsokproperty wasfalse.readErrormeans a native body reader rejected and retains both the response and rejection cause.The original rejection is retained as
exnrather than normalized into a string. This preservesthe value for inspection and avoids discarding useful error identity, message, or stack information.
Proposed Functions
checkOkcheckOkis a pure status classification helper:It is useful with an existing
Response.t, including one obtained from the raw API. It follows theplatform's definition of
Response.ok, including classifying opaque responses with status0asnot OK.
fetchandfetchWithRequestThese functions catch synchronous exceptions and promise rejections from their corresponding raw
bindings. A fulfilled response is then passed through
checkOk.The returned promise should fulfill with exactly one of these outcomes:
Keeping the non-OK
Response.tis important because APIs commonly return useful error headers andbodies.
Body Readers
Each body reader delegates to its matching raw
Responsefunction and catches its rejection. Itdoes not check
response.ok; status classification and body consumption remain separate,composable operations.
On success, the result contains the parsed body and original response:
The retained response has already had its body consumed. Consumers must not assume it can be read a
second time. Code that needs multiple reads must clone the response before consuming it, following
the native Fetch contract.
Usage
Common JSON Request
Reading an Error Body
Because a non-OK response is preserved, callers can still inspect its body safely:
Application JSON Decoding
SafeFetch.jsonshould returnJSON.t, matching the raw binding. Applications can then use atotal decoder whose own failures are typed for that domain:
This keeps malformed JSON text, which causes the native body reader to reject, separate from valid
JSON that has the wrong application shape.
Why Not a One-Shot
fetchJsonYet?A one-shot helper initially looks attractive:
It quickly produces a larger API matrix: URL versus
Request.t, each body representation, optionalapplication decoders, and a combined error type for Fetch, HTTP status, body reading, and decoding.
The proposed staged functions cover those workflows with a smaller surface and retain each failure
boundary.
After the primitive API has real usage, one-shot helpers can be considered using evidence from
common call sites. They can be added without breaking this design.
Error Semantics
Error(FetchRejected(cause))ok === falseError(ResponseNotOk({response}))Error({response, cause})ErrorThe wrapper should not convert arbitrary failures into messages or invent browser-independent error
categories. Consumers that care about abort handling can inspect the retained cause, while consumers
that do not can handle all request rejections uniformly.
Relationship to the Raw Bindings
This is an additive convenience layer, not a new foundation for the package:
Fetch,Request,Response, and related binding modules.SafeFetchonly when it is broadly useful and composeswith the raw values.
Packaging and Feature Gating
SafeFetch.resshould live insrc/fetch, be added to that source group's public module list, andremain available under the existing
WebAPI.Fetchfeature. It should not introduce a new feature ordependency.
Implementation Shape
The implementation should use a small internal body-reader helper so every public reader shares the
same rejection and response-preservation behavior:
Public functions then supply the existing raw reader, for example
Response.jsonorResponse.text. The exact exception pattern should be verified against the ReScript version used bythe repository during implementation.
Testing
Runtime tests should cover:
Ok(response);ResponseNotOkand preserves that exact response;FetchRejectedand the wrapper promise fulfills;Ok({response, body});readErrorrather than a rejected wrapper promise;readError;Feature checks should verify that
SafeFetchis public withWebAPI.Fetchand absent when theFetch feature is disabled.
Documentation
If accepted, add a short "Result-based workflows" section to the Fetch API documentation that:
FetchandResponseas the raw bindings;SafeFetchas opt-in convenience;JSON.tis application validation.Alternatives Considered
Add Safe Functions to
FetchandResponseThis is discoverable, but it mixes a convenience policy into a module whose current purpose is raw
platform bindings. The name also leaves body readers without a natural home.
Add
fetchResulttoFetchand result readers toResponseThis keeps functions near their raw equivalents but makes the raw modules a mixture of 1:1 bindings
and opinionated wrappers. A separate module makes the opt-in boundary visible at every call site.
Add
WebAPI.StdlibThis creates a large, vague namespace before there is evidence for a package-wide wrapper design.
It also makes feature ownership and gating less obvious. A Fetch-specific module can later inform a
broader pattern if other Web APIs develop similar wrappers.
Return only a string error
This is easy to log but destroys response bodies, headers, rejection identity, and structured error
handling. It would also force the library to choose messages that applications may need to parse.
Return only the decoded body
This is compact but discards status, headers, URL, redirection state, and other response metadata
that is commonly needed after a successful request.
Add a JSON decoder dependency
Decoder choice belongs to applications and higher-level HTTP libraries. Keeping
JSON.tavoids aproduction dependency and works with handwritten decoders or any decoding library.
Open Questions
SafeFetch.fetchcheckresponse.okby default, as proposed, or should rejected Fetchand HTTP status be two explicitly composed functions?
response<'body>as proposed, or only the body value?jsonandtext?exnthe desired public error representation, or should the wrapper expose a smallnormalized error record while retaining the original value?
fetchJson, after usage establishes thedesired combined error type?
Recommendation
Proceed with
WebAPI.SafeFetchas a focused experiment inside the Fetch feature. Start withcheckOk, the two Fetch entry points, and all existing response body readers. Preserve native valuesin every error and success case, add no dependencies, and wait for real call-site evidence before
adding one-shot request-and-decode helpers or a broader
WebAPI.Stdlibconcept.