rescript-lang / rescript-lang/experimental-rescript-webapi
Add an opt-in SafeFetch wrapper for result-based Fetch workflows
Nobody has claimed this yet.
- Dominant language
- ReScript
- Stars
- 40
- Forks
- 11
- Avg merge
- 3d 12h
- Merged PRs (30d)
- 12
Description
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 anErrorvalue; - a fulfilled response with
response.ok === falsebecomes a distinctErrorvalue; - response body reader rejections become
Errorvalues; and - successful body reads retain the original
Response.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 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,
forjson(), 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
resultvalues for expected Fetch and body-reading failures. - Keep rejected Fetch operations separate from non-successful HTTP responses.
- Preserve the native
Response.ton 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.Fetchfeature.
Non-Goals
- Replacing, deprecating, renaming, or changing
Fetch.fetch,Fetch.fetchWithRequest, or the
Responsebody 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.tinto 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:
FetchRejectedmeans the native Fetch promise rejected. It is intentionally not named
NetworkError, 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 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:
Ok({response, body})
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
SafeFetchonly 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
ResponseNotOkand preserves that exact response; - a rejected Fetch promise becomes
FetchRejectedand the wrapper promise fulfills; - valid JSON and text bodies return
Ok({response, body}); - malformed JSON becomes
readErrorrather 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
FetchandResponseas the raw bindings; - introduces
SafeFetchas opt-in convenience; - explains the difference between rejection, non-OK status, and body-reading failure; and
- includes one JSON example without implying that
JSON.tis 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.fetchcheckresponse.okby 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
jsonandtext? - Is retaining
exnthe 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.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the proposed src/fetch/SafeFetch.res and inspect the existing Fetch and Response bindings it must wrap. Verify the ReScript exception and async patterns, then check the existing fetch source group's public module list. Done means an opt-in SafeFetch module with the proposed result types, all listed readers, preserved Response.t values, and no changes to raw bindings or feature dependencies.
Written by the indexing model from the issue text.
Assessment
- Domain
- api, web-dev
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100