Type-safe Express APIs

tl;dr:
Making an Express API adhere to an OpenAPI specification

I was recently asked to provide API documentation for an unfamiliar Express API. It might've looked something like the ubiquitous pet store example.

OpenAPI is a popular specification for describing RESTful APIs that can be consumed by many tools to generate documentation, client code, and server code.

My first idea was to generate the API docs automatically from the code - an approach I'd used before with Python's FastAPI framwework. Unfortunately, I wasn't able to find a tool that could generate OpenAPI specs from Express application code.

My next idea was to start with an OpenAPI specification and use it to type-check the code as well as generate documentation.

Specification-driven development

Regardless of whether you start with the code or the specification, generating one from the other ensures that the two are always aligned and streamlines development through centralized updates.

Specification-driven development (i.e., starting with a specification and generating the code), offers the advantage of being highly standardized and language-agnostic. This makes it easier to get all stakeholders involved earlier in the development process.

This simple OpenApi specification, written in YAML, represents a pet store API with a single endpoint for retrieving a pet by its ID, which is provided in the url. The response is a JSON object with either the pet's id and name, or an error message.

With this specification, the API provider can implement it using the language of their choosing and generate API documentation. The API consumer can generate client code to using tools like openapi-generator-cli

Generating TypeScript types

The npm package openapi-typescript generates TypeScript types from an OpenAPI specification.

Invoking this command with the sample specification yields a TypeScript file with definitions for each request and response.

Express Types

Let's take a closer look at the Express route definition.

The second argument to app.get is an anonymous function with the type RequestHandler. Here's the definition provided by Express:

RequestHandler has five generic types parameters.

P
url path parameters
ResBody
the response body
ReqBody
the request body
ReqQuery
query string parameters
LocalData
local data attached to the response

Our goal is to combine our generated OpenAPI types and RequestHandler to produce something like this:

ShowPetByIdHandler is a type of RequestHandler that receives a single path parameter petId and returns an object representing a pet, or an error.

OpenAPI types in Express

We don't want to manually specify those generic type parameters though, they should come from the OpenAPI specification via openapi-typescript, giving us something like this:

OpenApiRequestHandler

Given an Operation, we can define the OpenApiRequestHandler type.

Locals is the type of the local data attached to the response object. Since it's unaffected by the API specification, it simply gets passed through.

Four intermediate types determine the path parameters, response body, request body, and query parameters based on the Operation. Let's look at each of these.

OpenApiPathParams

Path parameters are defined in Operation["parameters"]["path"]. The utility type Property is used two to extract the path parameters type.

So, OpenApiPathParams represents the type of Operation["parameters"]["path"] if it exists, or else undefined.

OpenApiQueryParams

Next, we'll jump to OpenApiQueryParams, since it's nearly the same. The only differences are the property path and MapToString.

It's possible to specify query parameters as numbers or booleans, but Express will always parse them as strings. MapToString is a utility type that converts non-string property types to string types. We ignore array and object parameters for the sake of simplicity.

OpenApiRequestBody

Now let's look at the third intermediate type, OpenApiRequestBody.

Using the Property utility type as before, we extract the request body type from Operation["requestBody"]["content"]. This contains a map of MIME types. The Properties utility type provides a union of the body types or undefined if there are none.

OpenApiResponseBody

The response body type has one more level of complexity; multiple responses with different MIME types.

The response body is defined for each response code and MIME type in Operation["responses"][responseCode]["content"][mimeType].

OpenApiResponseBody extracts and collects response types by response code and MIME type.

Type-checking the API

If you didn't follow all that it's ok! Here's the important part: typechecking against the OpenAPI specification.

Notice that apart from extracting the request handler and specifying it's type, the responses have also changed. That's because the types didn't match the API specification and the type checker caught it.

Type error

Here's the modified showPetByIdHandler type:

Testing

Finally, we can use the utility type Equal to verify that the RequestHandler was derived correctly.

Moving Forward

We've seen how to generate TypeScript types from an OpenAPI specification and use them to type-check an Express API. This approach ensures that the API documentation and code remain in sync as the API evolves.

By adding a script to a projects package.json, it's easy to rerun openapi-typescript whenever the specification changes.

Conclusion

In terms of the original goal of generating API documentation, this approach was quite successful. I started by taking a guess at the specification, or generating one by using online tools and observing requests and responses. The type-checker then guided me to the correct the code or the specification until the two were in agreeance.

One improvement that would be nice is a way to ensure the response code corresponds with the body type.

Resources