A simple wrapper around fetch I am writing a simple wrapper around fetch.
async function apiCall(
  endpoint: string,
  {
    data,
    headers: customHeaders,
    ...customConfig
  }: { data?: Object; headers?: Object } = {}
) {
  const config = {
    method: data ? 'POST' : 'GET',
    body: data ? JSON.stringify(data) : undefined,
    headers: {
      'content-type': data ? 'application/json' : undefined,
      ...customHeaders,
    },
    ...customConfig,
  }

  return fetch(endpoint, config as any).then(async (response) =&gt; {
    if (response.ok) {
      const json = await response.json() // 
      return json
    } else {
    //   what if `response` contains error messages in json format?
      return Promise.reject(new Error('Unknown Error'))
    }
  })
}

It works fine. The problem is with this snippet:
 return fetch(endpoint, config as any).then(async (response) =&gt; {
    if (response.ok) {
      const json = await response.json()
      return json
    } else {
    //   what if `response` contains error messages in json format?
      return Promise.reject(new Error('Unknown Error'))
    }
  })

If response is not ok, it rejects with a generic Error. This is because by default, window.fetch will only reject a promise if the actual network request failed. But the issue is that, even if response is not ok, it might still be able to have error messages in json format. This depends on the backend implementation details but sometimes you are able to get the error messages in the response body by response.json(). Now this use case is not covered in the wrapper I built.
So I wonder how I am going to be able to account for that? I guess you can do something like
fetch(endpoint, config as any).then(async (response) =&gt; {
      if (response.ok) {
        const json = await response.json()
        return json
      } else {
          try {
            const json = await response.json()
            return Promise.reject(json)
          } catch {
            return Promise.reject(new Error('Unknown Error'))
          }
      }
    })

I wonder if there is some more elegant way to do that?
Lastly, I am very aware of libraries like Axios. I built this partly to satisfy my intellectual curiosity.
By the way, a lightly unrelated question but I wonder if the following snippets are equivalent?
 if (response.ok) {
        const json = await response.json()
        return json
      }

 if (response.ok) {
        return response.json()
      }
If you need to account for mixed response types (in your case &quot;unknown errors&quot; or JSON-formatted errors), then you could consider checking the Content-Type of the response. See this StackOverflow answer for reference. This, of course, trusts in the server echoing the correct content type header for a response.
As you mentioned, fetch does not reject on HTTP error status, instead response.ok will be set to false. You can use this to decide if you want to reject or resolve your promise.
/* ... */.then(async response =&gt; {
    const contentType = response.headers.get(&quot;content-type&quot;);
    if (contentType &amp;&amp; contentType.indexOf(&quot;application/json&quot;) !== -1) {
        const json = await response.json();
        return response.ok
            ? Promise.resolve(json)
            : Promise.reject(json);
    } else {
        const text = await response.text();
        return response.ok
            ? Promise.resolve(text)
            : Promise.reject(new Error(&quot;Unknown Error!&quot;));
    }
});

You could also (which is what the post linked above suggests aswell) use a try-catch block, if you do not trust the response headers.
/* ... */.then(async response =&gt; {
    const text = await response.text();
    try {
        const data = JSON.parse(text);

        // If the line above has not produced an error, the response contained valid JSON.
        // We can either choose to just return the resulting data, or, as in
        // example above, resolve/reject the Promise based on the status code.

        return response.ok
            ? Promise.resolve(data);
            : Promise.reject(data);
    }
    catch(err) {
        // This means that the response probably contained text. As above, you can
        // decide if this means an error has occurred, or if the result will just
        // be handled differently.

        return response.ok
            ? Promise.resolve(text);
            : Promise.reject(new Error(&quot;Unknown Error!&quot;));
    }
});


As for your additional question on the difference between:
if (response.ok) {
    const json = await response.json();
    return json;
}
// ... and ...
if (response.ok) {
    return response.json();
}

This essentially boils down to the difference between:
return await response.json();
// ... and ...
return response.json();

The difference lies within the way that JavaScript handles async/await methods. The first line returns a generic T, while the second one returns Promise&lt;T&gt;. What this means is that the first one &quot;delays&quot; the execution of the return statement until the promise returned by response.json() is resolved, while the second one says &quot;I don't care that execution is continued, whoever calls me later will have to wait for my value to be resolved.&quot;, which is what happens when you perform .then(...) on a promise.
Note that the first method still ultimately returns a Promise&lt;T&gt; though, since async methods are essentially just fancy wrappers for promise generations.