Even though choosing Typescript to develop an express web app provides type safety in both client and server, the network requests data remains untyped on both ends, complicating our attempts to keep a consistent code base 🤬 With that idea in mind I wrote @express-typed-api, a library to help creating a type declaration for an express API so that it can be used to automatically infer the network requests' return type from the client side. Here is how to use it.
The problem
Usually, when making a Web API network request via fetch, we either settle with the any type of the response payload or we explicitly cast it to the type we expect it to be.
Explicitly casting the payload's type does provide type safety but it's not ideal: it must be done for each different fetch call and, more importantly, it is not linked to the actual endpoint's return type. When we change an endpoint's return type, we also need to change the explicit casts accordingly for all of the endpoint's fetch calls.
The idea behind @express-typed-api is generating a type declaration for the express API, with no dependencies on the server side, and in a way that Typescript can tell the return type of the API endpoints based on their path and method. That API type declaration is then provided to a fetch wrapper function, which uses the request URL and method to automatically infer the function return type. Let's get on with it!
Sample code
To better illustrate the explanations in this article I'll be applying the steps on a simple express API with a single get endpoint (i.e. /api/weather), that receives a city name via query string parameter and returns weather data for the specified city, and the corresponding client side fetch call. A fairly simple example, yet it involves all the necessary aspects to get automatic inferring going in any other express API.
1. API's type declaration
The first and obvious step is to define a type for our API, including each endpoint's path and method. A convenient way to generate such type consists in declaring a type containing the endpoints' return type as properties and using the endpoints' path/method as keys. This is what we are talking about:
When using @express-typed-api we will declare our endpoints using the EndpointHandler generic type like so. EndpointHandler is meant to enforce the return type of the endpoints' handler and provides explicit typing for the handlers' express arguments (i.e. req, res and next).
Make sure you create the API type declaration in a package/folder that can be imported from both client and server side, with @express-typed-api/common installed as a dependency.
2. Handlers' return type
The second step consists in making Typescript aware of the handlers' return type. Because express handlers send the response data by calling methods on the response object (e.g. res.json), Typescript is not aware of the handlers' return type (which is usually void). We will need to slightly change the implementation of each handler so that the data is returned as the function result instead.
A simple way to do so consists in splitting the handler's actual logic and the response object manipulation into two separate functions. One will run the corresponding business logic and return the appropriate data while the other will just call the corresponding response method. This is what it looks like for our sample express API:
Note that, even though the express response object offers several methods to return data (e.g. res.send), @express-typed-api only works with res.json.
Now Typescript gets access to the handler's return type 👌 When using @express-typed-api it's a good practice to type the endpoints' handler implementation with the types we created for the API type declaration, so we keep the handlers consistent with the type declaration.
Note that, because we sometimes want to set the response's HTTP status code before sending the payload, we will need our handlers to return an object with two properties: payload (the type we are actually interested in) and status (an optional HTTP status code). You can optionally use the EndpointResponse class to simplify the handlers' return statement.
3. Consistent API's implementation
The third step consists in validating that the endpoints' path and method used in the API's implementation match the values used in the API's type declaration. So far we have a type declaration for our API and we are using it in the handler's return type, but we would still run into trouble if we would change the path or method of an endpoint's handler but we forgot to update the corresponding part of the API's type declaration.
To prevent that from happening we will need to extract the path and method values from the express app.<method>(path, handler) calls and associate them with the corresponding handler so that Typescript can tell whether the values match the API's type declaration. A convenient way of doing so consists in mimicking the approach we used when declaring the API's type: creating an object containing the endpoints' handlers as properties and using the endpoints' path/method as keys.
Because we are typing such object with the API's type declaration, we make sure that the API's implementation will always be compliant with its type declaration. Now we only need to use the API representation object to replace the app.<method> calls: @express-typed-api/server exports a publishApi function for that purpose.
It receives an express app and an API representation object as parameters and it traverses the representation object properties, calling the necessary methods on the express app with the corresponding method, path and handler. This is what it looks like when used in our sample express API:
Heads up! Having both the API's type declaration and API representation object might feel like an unnecessary code duplication but it is indeed necessary. A couple remarks worth mentioning:
It would be possible to remove the API's type declaration and infer the type from the API representation object instead, but we would then be introducing dependencies on the server side code. Since we want to use the API's type declaration on the client side, that is not an option.
@express-typed-api exports an ApiEndpoints helper type to validate the API representation objects. Using it to type the API representation object results in obstructing the Typescript inferring:
4. Fetch requests' return type inferring
With the API's type declaration available on the client side the final step is using it to infer the return type of the fetch calls. @express-typed-api contains a typedFetch function, a fetch wrapper that receives the endpoints' path and method as parameters and, apart from passing them to the actual fetch call, uses them to cast the response's json payload.
typedFetch is exported in the @express-typed-api/client package via a getter function called getTypedFetch, that takes the API's type declaration as the type parameter and returns the corresponding instance of typedFetch. It can be called any number of times but the best practice is to call it once and use the returned function all across the client side code.
Note also that using query string parameters (and/or express URL parameters) in the request's URL will result in a Typescript error when using typedFetch. Because the API's type declaration is defined with the endpoints' path instead of the actual requests' URL, i.e. /api/weather instead of /api/weather?cityName=xyz (or /api/weather/:cityName instead of /api/weather/xyz), Typescript will only accept path arguments that match one of the paths in the API's type declaration.
typedFetch resolves this issue by taking the query string parameters (or URL parameters) in a separate query property (or params) and then building the actual request's URL before passing it down to the underlying fetch call. Applied to our sample express API fetch request:
The existence of getTypedFetch is meant to prevent code duplication in the typedFetch calls, as using typedFetch directly would require each call to provide type parameters, duplicating the function's arguments in the function's type parameters.
Additional features
Finally here are a couple other features that are likely to be needed in modern express APIs and @express-typed-api does support but, for the sake of simplicity, are not included in the sample express API. Plus a bonus feature to bring type safety to the request's payload as well 💪
express middleware. Sometimes we need to run additional middleware before executing an endpoint's handler (e.g. authentication, request parsing, etc.). Let's take the following POST endpoint, for which we want to execute express.json() before running the actual endpoint handler.
@express-typed-api exports a EndpointHandlerWithMiddleware type for such cases. When declaring an endpoint with this type its implementation will expect an object with two properties: the actual handler and middleware, a function that receives the endpoint handler and returns an array with any number of handlers that will be executed in that order.
baseUrl. The API's type declaration does not contain the endpoints' full URL but only relative paths. The getTypedFetch function accepts an optional baseUrl parameter that will prepend the provided value to all the typedFetch requests' URL. Note it can contain any value, not necessarily an absolute URL.
In a similar fashion the server publishApi method accepts a pathsPrefix parameter that can be used to remove a common prefix from the API's type declaration, while keeping it in the endpoints' actual path.
Typed request payload. @express-typed-api allows specifying the type of the requests' payload (i.e. body, params and query) by providing an optional second type parameter to EndpointHandler, containing any combination of jsonBody, params and/or query types. The types will then be enforced on both client requests and server endpoint handlers.
And that's pretty much it. There are some more examples in the sample repository, and both server and client packages are available on npm, containing the API documentation in the README. Give it a try and, hopefully, it will turn out to be useful for you too. Happy coding!