Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(fetch): add result-based SafeFetch wrapper
  • Loading branch information
jderochervlk committed Sep 22, 2026
commit 34977ccebdc49662d94c4b94fab07fd3eb7273dc
27 changes: 27 additions & 0 deletions docs/content/docs/api-surface.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion rescript.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,8 @@
"Headers",
"HeadersInit",
"Request",
"Response"
"Response",
"SafeFetch"
]
},
{
Expand Down
2 changes: 2 additions & 0 deletions src/fetch/Fetch.res
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
jderochervlk marked this conversation as resolved.
Outdated
external fetch: (string, ~init: Request.requestInit=?) => promise<Response.t> = "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<Response.t> =
"fetch"
57 changes: 57 additions & 0 deletions src/fetch/SafeFetch.res
Original file line number Diff line number Diff line change
@@ -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<Response.t, httpError> =>
if response.ok {
Ok(response)
} else {
Error({response: response})
}

let fetch = async (url: string, ~init: option<Request.requestInit>=?) => {
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<Request.requestInit>=?) => {
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)
33 changes: 33 additions & 0 deletions src/fetch/SafeFetch.resi
Original file line number Diff line number Diff line change
@@ -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<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>>
144 changes: 144 additions & 0 deletions tests/FetchAPI/SafeFetch__test.res
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer.t>,
SafeFetch.readError,
> = await Response.fromString("buffer")->SafeFetch.arrayBuffer
assert(arrayBufferResult->Result.isOk)

let blobResult: result<SafeFetch.response<Blob.t>, SafeFetch.readError> = await Response.fromString(
"blob",
)->SafeFetch.blob
assert(blobResult->Result.isOk)

let bytesResult: result<
SafeFetch.response<array<int>>,
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<FormData.t>,
SafeFetch.readError,
> = await formDataResponse->SafeFetch.formData
assert(formDataResult->Result.isOk)
1 change: 1 addition & 0 deletions tests/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];
Expand Down
Loading