From 34977ccebdc49662d94c4b94fab07fd3eb7273dc Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Tue, 22 Sep 2026 09:25:18 -0400 Subject: [PATCH 1/3] feat(fetch): add result-based SafeFetch wrapper --- docs/content/docs/api-surface.mdx | 27 ++++++ rescript.json | 3 +- src/fetch/Fetch.res | 2 + src/fetch/SafeFetch.res | 57 ++++++++++++ src/fetch/SafeFetch.resi | 33 +++++++ tests/FetchAPI/SafeFetch__test.res | 144 +++++++++++++++++++++++++++++ tests/index.js | 1 + 7 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 src/fetch/SafeFetch.res create mode 100644 src/fetch/SafeFetch.resi create mode 100644 tests/FetchAPI/SafeFetch__test.res diff --git a/docs/content/docs/api-surface.mdx b/docs/content/docs/api-surface.mdx index 798bc3ec..f7026243 100644 --- a/docs/content/docs/api-surface.mdx +++ b/docs/content/docs/api-surface.mdx @@ -113,6 +113,33 @@ let response = await WebAPI.Fetch.fetch( let text = await response->WebAPI.Response.text ``` +### Result-based workflows + +`WebAPI.Fetch` and `WebAPI.Response` are the raw browser bindings. Use the opt-in +`WebAPI.SafeFetch` helpers when expected request, HTTP status, and body-reading failures should be +handled as `result` values instead of rejected promises. + +```ReScript +switch await WebAPI.SafeFetch.fetch("https://example.com/api/users") { +| Error(WebAPI.SafeFetch.FetchRejected(cause)) => handleUnavailable(cause) +| Error(WebAPI.SafeFetch.ResponseNotOk({response})) => handleHttpError(response.status) +| Ok(response) => + switch await response->WebAPI.SafeFetch.json { + | Error({response, cause}) => handleInvalidBody(response.status, cause) + | Ok({response, body}) => handleJson(response.headers, body) + } +} +``` + +`FetchRejected` means the native Fetch operation rejected before producing a response. +`ResponseNotOk` means Fetch produced a response whose `ok` field is `false`, so its headers and body +remain available. A body-reader error retains both the response and the original rejection cause. +Successful readers also retain the response, whose body has then been consumed according to the +native Fetch contract. + +`SafeFetch.json` returns `JSON.t`; validating that JSON against an application's domain types remains +the application's responsibility. + `BodyInit` has constructors for the common JavaScript body shapes. ```ReScript diff --git a/rescript.json b/rescript.json index ce2f933b..1e25cd67 100644 --- a/rescript.json +++ b/rescript.json @@ -330,7 +330,8 @@ "Headers", "HeadersInit", "Request", - "Response" + "Response", + "SafeFetch" ] }, { diff --git a/src/fetch/Fetch.res b/src/fetch/Fetch.res index 05fdb315..c005e083 100644 --- a/src/fetch/Fetch.res +++ b/src/fetch/Fetch.res @@ -2,11 +2,13 @@ Starts the process of fetching a resource from the network, returning a promise that is fulfilled once the response is available. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ +@scope("globalThis") external fetch: (string, ~init: Request.requestInit=?) => promise = "fetch" /** Starts the process of fetching a resource from the network, returning a promise that is fulfilled once the response is available. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ +@scope("globalThis") external fetchWithRequest: (Request.t, ~init: Request.requestInit=?) => promise = "fetch" diff --git a/src/fetch/SafeFetch.res b/src/fetch/SafeFetch.res new file mode 100644 index 00000000..dc418b4a --- /dev/null +++ b/src/fetch/SafeFetch.res @@ -0,0 +1,57 @@ +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, +} + +let checkOk = (response: Response.t): result => + if response.ok { + Ok(response) + } else { + Error({response: response}) + } + +let fetch = async (url: string, ~init: option=?) => { + try { + let response = await Fetch.fetch(url, ~init?) + response->checkOk->Result.mapError(error => ResponseNotOk(error)) + } catch { + | cause => Error(FetchRejected(cause)) + } +} + +let fetchWithRequest = async (request: Request.t, ~init: option=?) => { + try { + let response = await Fetch.fetchWithRequest(request, ~init?) + response->checkOk->Result.mapError(error => ResponseNotOk(error)) + } catch { + | cause => Error(FetchRejected(cause)) + } +} + +let read = async (response: Response.t, readBody: Response.t => promise<'body>) => { + try { + Ok({response, body: await readBody(response)}) + } catch { + | cause => Error({response, cause}) + } +} + +let arrayBuffer = response => read(response, Response.arrayBuffer) +let blob = response => read(response, Response.blob) +let bytes = response => read(response, Response.bytes) +let formData = response => read(response, Response.formData) +let json = response => read(response, Response.json) +let text = response => read(response, Response.text) diff --git a/src/fetch/SafeFetch.resi b/src/fetch/SafeFetch.resi new file mode 100644 index 00000000..98e927de --- /dev/null +++ b/src/fetch/SafeFetch.resi @@ -0,0 +1,33 @@ +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, +} + +let checkOk: Response.t => result + +let fetch: (string, ~init: Request.requestInit=?) => promise> + +let fetchWithRequest: ( + Request.t, + ~init: Request.requestInit=?, +) => promise> + +let arrayBuffer: Response.t => promise, readError>> +let blob: Response.t => promise, readError>> +let bytes: Response.t => promise>, readError>> +let formData: Response.t => promise, readError>> +let json: Response.t => promise, readError>> +let text: Response.t => promise, readError>> diff --git a/tests/FetchAPI/SafeFetch__test.res b/tests/FetchAPI/SafeFetch__test.res new file mode 100644 index 00000000..59d5298f --- /dev/null +++ b/tests/FetchAPI/SafeFetch__test.res @@ -0,0 +1,144 @@ +@scope("Object") +external isSame: ('value, 'value) => bool = "is" + +let okResponse = Response.fromString("ok", ~init={status: 200}) +switch okResponse->SafeFetch.checkOk { +| Ok(response) => assert(isSame(response, okResponse)) +| Error(_) => assert(false) +} + +let notOkResponse = Response.fromString("missing", ~init={status: 404}) +switch notOkResponse->SafeFetch.checkOk { +| Error({response}) => assert(isSame(response, notOkResponse)) +| Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response("ok", {status: 200}) +globalThis.fetch = (_input, init) => { + globalThis.__safeFetchInit = init + return Promise.resolve(globalThis.__safeFetchResponse) +} +`) + +switch await SafeFetch.fetch("https://example.com/ok", ~init={method: "POST"}) { +| Ok(response) => { + let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) + let method: string = %raw(`globalThis.__safeFetchInit.method`) + assert(isSame(response, expected)) + assert(method == "POST") + } +| Error(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response("missing", {status: 404}) +globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) +`) + +switch await SafeFetch.fetch("https://example.com/missing") { +| Error(ResponseNotOk({response})) => { + let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) + assert(isSame(response, expected)) + } +| Error(FetchRejected(_)) | Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchCause = new TypeError("request rejected") +globalThis.fetch = () => Promise.reject(globalThis.__safeFetchCause) +`) + +switch await SafeFetch.fetch("https://example.com/rejected") { +| Error(FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +} + +let request = Request.fromURL("https://example.com/synchronous") + +%%raw(` +globalThis.__safeFetchCause = new TypeError("request threw") +globalThis.fetch = () => { throw globalThis.__safeFetchCause } +`) + +switch await SafeFetch.fetchWithRequest(request) { +| Error(FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +} + +let textResponse = Response.fromString("hello") +switch await textResponse->SafeFetch.text { +| Ok({response, body}) => { + assert(isSame(response, textResponse)) + assert(body == "hello") + } +| Error(_) => assert(false) +} + +let jsonResponse = Response.fromString(`{"name":"Ada"}`) +switch await jsonResponse->SafeFetch.json { +| Ok({response, body}) => { + let _json: JSON.t = body + assert(isSame(response, jsonResponse)) + } +| Error(_) => assert(false) +} + +let malformedResponse = Response.fromString("not json") +switch await malformedResponse->SafeFetch.json { +| Error({response, cause: _}) => assert(isSame(response, malformedResponse)) +| Ok(_) => assert(false) +} + +let consumedResponse = Response.fromString("once") +let _ = await consumedResponse->Response.text +switch await consumedResponse->SafeFetch.text { +| Error({response, cause: _}) => assert(isSame(response, consumedResponse)) +| Ok(_) => assert(false) +} + +let errorBodyResponse = Response.fromString("details", ~init={status: 500}) +switch await errorBodyResponse->SafeFetch.text { +| Ok({response, body}) => { + assert(isSame(response, errorBodyResponse)) + assert(body == "details") + } +| Error(_) => assert(false) +} + +let arrayBufferResult: result< + SafeFetch.response, + SafeFetch.readError, +> = await Response.fromString("buffer")->SafeFetch.arrayBuffer +assert(arrayBufferResult->Result.isOk) + +let blobResult: result, SafeFetch.readError> = await Response.fromString( + "blob", +)->SafeFetch.blob +assert(blobResult->Result.isOk) + +let bytesResult: result< + SafeFetch.response>, + SafeFetch.readError, +> = await Response.fromString("bytes")->SafeFetch.bytes +assert(bytesResult->Result.isOk) + +let formDataResponse = Response.fromString( + "fruit=peach", + ~init={ + headers: HeadersInit.fromDict(dict{"Content-Type": "application/x-www-form-urlencoded"}), + }, +) +let formDataResult: result< + SafeFetch.response, + SafeFetch.readError, +> = await formDataResponse->SafeFetch.formData +assert(formDataResult->Result.isOk) diff --git a/tests/index.js b/tests/index.js index c989c737..1547bb4f 100644 --- a/tests/index.js +++ b/tests/index.js @@ -16,6 +16,7 @@ const runtimeTests = [ "FetchAPI/Headers__test.res", "FetchAPI/Request__test.res", "FetchAPI/Response__test.res", + "FetchAPI/SafeFetch__test.res", "FetchAPI/URLSearchParams__test.res", "URLAPI/URL__test.res", ]; From 3cceaa8cc5123bc625e200845f3de516176f4fcd Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Tue, 22 Sep 2026 09:56:40 -0400 Subject: [PATCH 2/3] fix(fetch): keep raw Fetch bindings unchanged --- src/fetch/Fetch.res | 2 -- src/fetch/SafeFetch.res | 4 ++-- src/fetch/SafeFetchRaw.res | 4 ++++ 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 src/fetch/SafeFetchRaw.res diff --git a/src/fetch/Fetch.res b/src/fetch/Fetch.res index c005e083..05fdb315 100644 --- a/src/fetch/Fetch.res +++ b/src/fetch/Fetch.res @@ -2,13 +2,11 @@ Starts the process of fetching a resource from the network, returning a promise that is fulfilled once the response is available. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ -@scope("globalThis") external fetch: (string, ~init: Request.requestInit=?) => promise = "fetch" /** Starts the process of fetching a resource from the network, returning a promise that is fulfilled once the response is available. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ -@scope("globalThis") external fetchWithRequest: (Request.t, ~init: Request.requestInit=?) => promise = "fetch" diff --git a/src/fetch/SafeFetch.res b/src/fetch/SafeFetch.res index dc418b4a..fabffd05 100644 --- a/src/fetch/SafeFetch.res +++ b/src/fetch/SafeFetch.res @@ -25,7 +25,7 @@ let checkOk = (response: Response.t): result => let fetch = async (url: string, ~init: option=?) => { try { - let response = await Fetch.fetch(url, ~init?) + let response = await SafeFetchRaw.fromUrl(url, ~init?) response->checkOk->Result.mapError(error => ResponseNotOk(error)) } catch { | cause => Error(FetchRejected(cause)) @@ -34,7 +34,7 @@ let fetch = async (url: string, ~init: option=?) => { let fetchWithRequest = async (request: Request.t, ~init: option=?) => { try { - let response = await Fetch.fetchWithRequest(request, ~init?) + let response = await SafeFetchRaw.fromRequest(request, ~init?) response->checkOk->Result.mapError(error => ResponseNotOk(error)) } catch { | cause => Error(FetchRejected(cause)) diff --git a/src/fetch/SafeFetchRaw.res b/src/fetch/SafeFetchRaw.res new file mode 100644 index 00000000..d0dd3590 --- /dev/null +++ b/src/fetch/SafeFetchRaw.res @@ -0,0 +1,4 @@ +let fromUrl = (url: string, ~init: option=?) => Fetch.fetch(url, ~init?) + +let fromRequest = (request: Request.t, ~init: option=?) => + Fetch.fetchWithRequest(request, ~init?) From eb25639f8de1cb3051151caa7eeb4f63efa57d08 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Tue, 22 Sep 2026 09:58:55 -0400 Subject: [PATCH 3/3] docs(fetch): document SafeFetch workflows --- docs/content/docs/api-surface.mdx | 53 +++++++++++++++++--- src/fetch/SafeFetch.resi | 81 +++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/docs/content/docs/api-surface.mdx b/docs/content/docs/api-surface.mdx index f7026243..d2eed2ff 100644 --- a/docs/content/docs/api-surface.mdx +++ b/docs/content/docs/api-surface.mdx @@ -116,8 +116,20 @@ let text = await response->WebAPI.Response.text ### Result-based workflows `WebAPI.Fetch` and `WebAPI.Response` are the raw browser bindings. Use the opt-in -`WebAPI.SafeFetch` helpers when expected request, HTTP status, and body-reading failures should be -handled as `result` values instead of rejected promises. +`WebAPI.SafeFetch` helpers when request, HTTP status, and body-reading failures should be handled as +`result` values. The wrapper keeps those failure stages separate and preserves the native values +needed to inspect each outcome. + +| Stage | Result | Preserved context | +| ----------------------- | ---------------------------------- | ------------------------------- | +| Fetch throws or rejects | `Error(FetchRejected(cause))` | Original rejection as `exn` | +| Response is not OK | `Error(ResponseNotOk({response}))` | Complete response | +| Body reader rejects | `Error({response, cause})` | Response and original rejection | + +`SafeFetch.fetch` accepts a URL, while `SafeFetch.fetchWithRequest` accepts an existing +`WebAPI.Request.t`. Both promises fulfill with a `result` for these expected failures. A fulfilled +response is classified with the platform's `response.ok` value, so a 404 or 500 is distinct from a +request that did not produce a response. ```ReScript switch await WebAPI.SafeFetch.fetch("https://example.com/api/users") { @@ -131,14 +143,39 @@ switch await WebAPI.SafeFetch.fetch("https://example.com/api/users") { } ``` -`FetchRejected` means the native Fetch operation rejected before producing a response. -`ResponseNotOk` means Fetch produced a response whose `ok` field is `false`, so its headers and body -remain available. A body-reader error retains both the response and the original rejection cause. -Successful readers also retain the response, whose body has then been consumed according to the -native Fetch contract. +Use `checkOk` to classify a response obtained from the raw API without reading its body. + +```ReScript +let response = await WebAPI.Fetch.fetch("https://example.com/api/users") + +switch response->WebAPI.SafeFetch.checkOk { +| Ok(response) => useResponse(response) +| Error({response}) => handleHttpError(response.status) +} +``` + +A non-OK response remains readable. This is useful when a server returns structured error details. + +```ReScript +switch await WebAPI.SafeFetch.fetch("https://example.com/api/users/42") { +| Error(WebAPI.SafeFetch.ResponseNotOk({response})) => + switch await response->WebAPI.SafeFetch.text { + | Ok({body}) => Console.error(body) + | Error(_) => Console.error(`Request failed with ${Int.toString(response.status)}`) + } +| Error(WebAPI.SafeFetch.FetchRejected(cause)) => reportUnavailable(cause) +| Ok(response) => useResponse(response) +} +``` + +The safe body readers cover `arrayBuffer`, `blob`, `bytes`, `formData`, `json`, and `text`. A +successful read contains both the body and original response, preserving status, headers, URL, and +other metadata. Reading consumes the native body; clone the response before reading when another +consumer also needs it. `SafeFetch.json` returns `JSON.t`; validating that JSON against an application's domain types remains -the application's responsibility. +the application's responsibility. This keeps malformed JSON text, which becomes a `readError`, +separate from valid JSON that does not match an application schema. `BodyInit` has constructors for the common JavaScript body shapes. diff --git a/src/fetch/SafeFetch.resi b/src/fetch/SafeFetch.resi index 98e927de..848d085d 100644 --- a/src/fetch/SafeFetch.resi +++ b/src/fetch/SafeFetch.resi @@ -1,33 +1,114 @@ +/** +Describes a response whose `ok` field is `false`. + +The original response is retained so callers can inspect its status, headers, and body. +*/ type httpError = { response: Response.t, } +/** +Describes an expected failure while producing or classifying a response. + +- `FetchRejected(cause)` means the native Fetch operation threw or its promise rejected. Fetch does + not expose a reliable browser-independent distinction between network, abort, permission, and + other request failures. +- `ResponseNotOk({response})` means Fetch fulfilled with a response whose `ok` field is `false`. + The response remains available for inspection and body reading. +*/ type fetchError = | FetchRejected(exn) | ResponseNotOk(httpError) +/** +Describes a rejected response body reader. + +The record retains both the response whose body was being consumed and the original rejection +cause. +*/ type readError = { response: Response.t, cause: exn, } +/** +A successfully read body paired with its original response. + +The response body has been consumed. Clone the response before reading when another consumer also +needs the body. +*/ type response<'body> = { response: Response.t, body: 'body, } +/** +Classifies an existing response using its native `ok` field. + +Returns `Ok(response)` for successful responses and `Error({response})` otherwise. This function +does not read the body and is useful with responses obtained from the raw `Fetch` API. +*/ let checkOk: Response.t => result +/** +Fetches a URL and returns expected request and HTTP status failures as values. + +The returned promise fulfills with `Error(FetchRejected(cause))` when the native operation throws or +rejects, `Error(ResponseNotOk({response}))` when the response is not OK, or `Ok(response)` for an OK +response. +*/ let fetch: (string, ~init: Request.requestInit=?) => promise> +/** +Fetches an existing `Request.t` and returns expected request and HTTP status failures as values. + +This has the same result contract as `fetch` and accepts an optional request initializer that is +passed to the native Fetch operation. +*/ let fetchWithRequest: ( Request.t, ~init: Request.requestInit=?, ) => promise> +/** +Reads the response body as an `ArrayBuffer.t`. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ let arrayBuffer: Response.t => promise, readError>> + +/** +Reads the response body as a `Blob.t`. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ let blob: Response.t => promise, readError>> + +/** +Reads the response body as an array of bytes. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ let bytes: Response.t => promise>, readError>> + +/** +Reads the response body as `FormData.t`. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ let formData: Response.t => promise, readError>> + +/** +Parses the response body as `JSON.t`. + +Malformed JSON and other native reader rejections become `readError` values. This function does not +validate the parsed JSON against an application domain type. +*/ let json: Response.t => promise, readError>> + +/** +Reads the response body as a string. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ let text: Response.t => promise, readError>>