From a old project of my, I found this simple typescript Result<T, E>:

export type Ok<T> = {
  __result: 'OK';
  data: T;
};

export type Err<E> = {
  __result: 'ERR';
  err: E;
};

export const ok = <T>(data: T): Ok<T> => ({ __result: 'OK', data });
export const err = <E>(err: E): Err<E> => ({ __result: 'ERR', err });

export type Result<T, E> = Ok<T> | Err<E>;

export const isOk = <T, E>(r: Result<T, E>): r is Ok<T> => r.__result === 'OK';
export const isErr = <T, E>(r: Result<T, E>): r is Err<E> =>
  r.__result === 'ERR';

I remember I tried to add utilities like andThen and so, but it quickly turned out to be just wrapper function around a wrapper function which resulted into hard to follow code. That could be solved with using a class instead of plain object, but that would make server side rendering a lot harder.

The fetch API was then wrapped into something like this (simplified and untested for demonstration purposes):

// ResponseError represents responses that return with 400 statsu code. The `err`
// property contains the prased JSON body of the response.
export type ResponseError<E> = {
  __kind: 'RESPONSE_ERROR';
  err: E;
};

// UnexpectedError is what one of the following:
//   - Unexpected response code
//   - Unexpected response body
//   - Local error with fetch
//   - Network error
export type UnexpectedError = {
  __kind: 'UNEXPECTED_ERROR';
};

export type GetError<E> =
  | ResponseError<E>
  | UnexpectedError;

export type GetResult<T, E> = Result<T, GetError<E>>;

export const getJSON = <T, E>(path: string): Promise<GetResult<T, E>> => {
  return fetch(path, { method: 'GET' })
    .then(async res => {
      if (res.ok) {
        return await res
          .json()
          .then(ok)
          .catch(() => err({ __kind: 'UNEXPECTED_ERROR' }))
      }

      // 400 always returns JSON body.
      if (res.status === 400) {
        return await res
          .json()
          .then(e => err({ __kind: 'RESPONSE_ERROR', err: e }))
          .catch(() => err({ __kind: 'UNEXPECTED_ERROR' }))
      }

      return err({ __kind: 'UNEXPECTED_ERROR' });
    })
    .catch(() => err({ __kind: 'UNEXPECTED_ERROR' }));
}

Nice thing about abstraction like this for me is that the caller will always get a value that is a result because all the failure points are catched. In other words, you’re calling site will look like this:

const res = await getJSON('/thingy');

if (isErr(res)) {
  // Handle error.
  return;
}

// Access res.data freely.

In the code where I took this example, there remains one issue tho’: you might want to distinguish error cases between local errors and network errors. In that old code I tried to do that but omitted from the above code because it was quite messy (and it still is as you can see), mainly because you need to annotate the different errors somehow with union types.