typed-http-client
TypeScript icon, indicating that this package has built-in type declarations

2.2.6 • Public • Published

codecov

typed-http-client

A TypeScript HTTP client that facilitates strongly typed requests and responses.

Introduction

This is a simple, strongly typed HTTP client that works in Node.js, a browser, and the React Native runtime environment (thanks to cross-fetch). It provides two main features:

  1. Strongly typed requests
  2. Strongly typed responses

Strongly typed requests

Being able to specify the shape of what the request functions (get, post, et al) accept as a shape for the data, in combination with the particular content type makes it easy and simple to know what's going out the door.

Strongly typed responses

Using the hook provided for processing a response, the caller can define the means to both parse the response from the backend and provide the typing information for it.

Using tools like type assertion functions, the caller can easily maintain type safety while providing a means of flow control if necessary for any problematic responses.

Type assertion function are similar to type predicates, but rely on errors being thrown to tell the rest of the code whether or not the argument passed matches the anticipated type. If no error is thrown—and it can be any error type—then the argument passed must be what was anticipated. Because any error type can be thrown, you can use different error types to provide for different error handling logic, and build that error however you desire.

For example, you may be able to recognize when a login attempt failed because of invalid credentials and throw a custom InvalidCredentialsError.

Getting Started

Installation

npm install --save typed-http-client

Usage

Let's start with the meat of it. What's it like to use the results? Importing looks like this:

import { TypedHttpClient } from "typed-http-client";

Now let's take a peak at how it'll look in your code if you need to send a POST request to a particular endpoint that sends back some simple JSON data:

const client = new TypedHttpClient("my-client");
const url = new URL("https://www.somecoolwebsite.com/post-endpoint");
// It's not necessary to tell response or data what types they'll be as it's inferred. But
// it's added here for clarity.
const response: ITypedResponse<MyProcessedData> =
  await client.post<MyProcessedData>({ url }, parseMyRawData);
const data: MyProcessedData = response.result;

You might have some questions, so let's go through it and how you can get to this point by working backwards.

client is the HTTP client itself. Nothing fancy, except for "my-client" which is just the user agent that the client will be using. You can put whatever you want in there.

url is the URL object that contains the information about where to make the request to. It's built in to JavaScript, so nothing to worry about there.

Let's look at that third line of code without the explicit typing (it can be inferred based on the arguments passed anyway):

const response = await client.post({ url }, parseMyRawData);

Ultimately each, every request you make with the client needs to provide the RequestOptions where how to make the request (here it's just { url }, but there's more options available), and a processing function that takes the raw response from the server and returns back out the data you want. You can see that response processing function, parseMyRawData, being passed as the second argument to the post method. In this case, we'll (hopefully) be receiving this in the response back from the server:

export interface MyRawData {
  someNumber: number;
  someDate: string; // this is the field we wanna transform
}

And we'll be using the response processor to turn it into this:

export interface MyProcessedData {
  someNumber: number;
  someDate: Date; // this is the field that changed
}

We'll be turning that string field, someDate (which should be in ISO 8601 format), into a Date object.

Of course, things can easily go wrong when working over a network, so we might not actually be getting back data in the shape of MyRawData. It might not even be a normal object with keys and stuff! It could just be a string, null, or even undefined! The client will grab JSON and provide it to the response processor if it can find any, but we can't know ahead of time what we'll be getting, so we have to cover our bases.

To do that, let's define our response processing function, parseMyRawData:

function parseMyRawData({
  response,
  responseBodyAsString,
  responseBodyAsObject,
}: ResponseProcessorParams): MyProcessedData {
  // responseBodyAsObject is now recognized as being of type `unknown`
  assertIsObject(responseBodyAsObject);
  // responseBodyAsObject is now recognized as being of type `object`
  assertIsMyRawData(responseBodyAsObject);
  // responseBodyAsObject is now recognized as `MyRawData`
  return {
    someNumber: responseBodyAsObject.someNumber,
    someDate: new Date(responseBodyAsObject.someDate),
  };
}

You probably already have more questions, so let's break this one down too. The three arguments to the function are what the client provides to all response processing functions.

The first is the full Response object. It's exactly what the client is working with when it gets a response back from the server.

The second is the response body as a string, with no processing done to it. It could very well be stringified JSON.

The third is the response body as an object. It'll try to parse JSON responses if the headers indicate it's JSON, but it doesn't know what that'll look like, and if there's no headers telling it there's JSON, it won't try. That's why its type is unknown. It could be an object, null, undefined, a string, or a number of other things, so we have to be careful with it. It's important that it's recognized as unknown first for type safety reasons. We actually have no way of knowing what it is, and it's "illegal" to perform any operations on anything of type unknown. Everything should be assumed to be unknown unless we can confirm otherwise for maximum type safety.

That's where the type assertion functions come in. Type assertion functions can take arguments and confirm for you (by not throwing an error) that something is a particular type. If it throws an error, it definitely isn't that type of data, but it is a real error that'll break the flow of the code so be careful with these. Then again, the errors can be a very powerful tool when combined with try/catch blocks.

First we use a type assertion function to ensure the argument is an actual object. For this example, we're using assertIsObject, provided by primitive-predicates. This library is used because things get tricky with object and null (since typeof responseBodyAsObject === "object" would also be true for null). That library provides a number of other useful type guard functions, like hasProperty that we'll see in this next example.

Then assertIsMyRawData comes in to make sure that the data is in the shape compatible with MyRawData. Let's look at how it works:

function assertIsMyRawData(value: object): asserts value is MyRawData {
  if (!hasProperty(value, "someNumber") || !Number.isFinite(value.someNumber)) {
    throw new TypeError("Value is not MyRawData");
  } else if (
    !hasProperty(value, "someDate") ||
    typeof value.someDate !== "string"
  ) {
    throw new TypeError("Value is not MyRawData");
  }
}

Even though we've ruled out errors being thrown when trying to access properties, the compiler will still complain about referencing properties it doesn't know are there. hasProperty tells the compiler that the property exists, but the property type is still unknown.

Once the property is confirmed to exist, we can check its type, and repeat the process for the other property. If it makes it through without throwing an error, then it's all good!

Once this is executed, it tells the rest of the code in parseMyRawData that the variable is in fact of type MyRawData, so the only thing left to do is change that someDate field and return the result as type MyProcessedData.

It's also important to note that, in this case, the type assertion function is where we need to be careful, because there's nothing inherently making sure we're checking everything we should to figure out if the incoming data is MyRawData. The function could be completely empty and we would be none the wiser.

With all that covered, though, we're done. The result of the post method will be an object that contains a reference to the object returned by our response processing function, along with references to a few other things that might be helpful in more advanced use cases.

Payloads

Now let's say the same endpoint took a JSON payload. The client comes with two content type handlers (one for JSON, and another for x-www-form-urlencoded), but attempts to use JSON by default. Let's define a quick interface that outlines the shape of the data we want to send to the endpoint:

export interface MyPayload {
  someId: number;
  specialFlag: boolean;
}

Now we can use this to make sure that we're passing exactly what we think to the post function:

const payload: MyPayload = {
  someId: 1234,
  specialFlag: true,
};
const client = new TypedHttpClient("my-client");
const url = new URL("https://www.somecoolwebsite.com/post-endpoint");
// Specifying the payload type here helps make sure we don't accidentally pass something in we don't want to.
const response = await client.post<MyProcessedData, MyPayload>(
  {
    url,
    payload,
  },
  parseMyRawData
);
const data: MyProcessedData = response.result;

And that's it!

Errors can be thrown at any point in the response processor and they'll bubble up to whatever is calling the client's methods. So you can get fancy in the processor to throw specific error depending on what went wrong so the caller can handle each one differently. That might look something like this:

try {
  const client = new TypedHttpClient("my-client");
  const url = new URL("https://www.somecoolwebsite.com/post-endpoint");
  const response = await client.post({ url }, parseMyRawData);
  return response.result;
} catch (err) {
  // UnauthorizedError would be a custom error you'd define.
  if (err instanceof UnauthorizedError) {
    // do something special
  }
  // throw the other kinds of errors that weren't handled
  throw err;
}

For an example of how to define a custom error, check out the errors.ts module in the source code.

Revivers

To simplify the processing before it reaches your response processor, you may want to try to transform some primitives into more convenient types like Date objects, since those can't be sent over the wire in JSON. The JSON.parse function accepts an argument it calls a "reviver" that does exactly this. You can think of it like a preprocessor for the response if it's JSON.

The client allows you to pass your own reviver function along with your request options if desired. And a couple Date-focused revivers are provided with the client that transform ISO 8601 formatted strings if encountered. You can define your own using them as an example.

Note that the typing information is still unknown by the compiler, so the response processing function will still be very much in the dark until it actually checks, but this can save a bit of effort by making it easy to check things.

Here's what using them looks like:

const client = new TypedHttpClient("my-client");
const url = new URL("https://www.somecoolwebsite.com/post-endpoint");
const requestOptions: RequestOptions = {
  url,
  responseJsonReviver: JsonISO8601DateAndTimeReviver, // right here
};

const response = await client.post(requestOptions, parseMyRawData);
const data: MyProcessedData = response.result;

Of course, our MyRawData interface now needs to change to accomodate this update. In fact, there wouldn't be anything to do with the data if it matches up in this case, so let's just simplify things a bit and keep it to one interface:

export interface MyData {
  someNumber: number;
  someDate: Date;
}

Here's how our type assertion function changes:

function assertIsMyData(value: object): asserts value is MyData {
  if (!hasProperty(value, "someNumber") || !Number.isFinite(value.someNumber)) {
    throw new TypeError("Value is not MyData");
  } else if (
    !hasProperty(value, "someDate") ||
    !value.someDate instanceof Date
  ) {
    // right here
    throw new TypeError("Value is not MyData");
  }
}

And our response processor now looks like this (we can also strip out the parameters we don't need):

function parseMyRawData({
  responseBodyAsObject,
}: ResponseProcessorParams): MyData {
  assertIsObject(responseBodyAsObject);
  assertIsMyData(responseBodyAsObject);
  // responseBodyAsObject is now recognized as `MyData`
  return responseBodyAsObject;
}

Build and Test

Building

npm install
npm run build

Testing

npm run test

Dependencies (1)

Dev Dependencies (21)

Package Sidebar

Install

npm i typed-http-client

Weekly Downloads

6

Version

2.2.6

License

MIT

Unpacked Size

571 kB

Total Files

221

Last publish

Collaborators

  • salmonmode