rescript-lang / rescript-lang/experimental-rescript-webapi

Add an opt-in SafeFetch wrapper for result-based Fetch workflows

Open
#363 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement
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 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:

  1. fetch() rejects because the request could not produce a response. This includes aborts,
    malformed requests, permissions failures, and network failures.
  2. fetch() fulfills with a response whose HTTP status is not successful. Native Fetch deliberately
    does not reject for statuses such as 404 or 500.
  3. A response body reader rejects. A body may be aborted, disturbed, locked, incorrectly encoded, or,
    for json(), syntactically invalid.
  4. 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:

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 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

  1. Should SafeFetch.fetch check response.ok by default, as proposed, or should rejected Fetch
    and HTTP status be two explicitly composed functions?
  2. Should successful body readers return response<'body> as proposed, or only the body value?
  3. Should the first version include all six existing body readers, or begin with json and text?
  4. Is retaining exn the desired public error representation, or should the wrapper expose a small
    normalized error record while retaining the original value?
  5. 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.