Type-safe Express APIs
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.
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.
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.