We made our development 10x faster by typing our API interface

In a time long forgotten, there was a web framework called Meteor. Meteor let you share code across your frontend and backend.

This code sharing let you avoid the hassle of creating a REST API and keeping your client and server in sync. With Meteor, you would write Meteor.call and the Meteor service would translate that into an HTTP request.

While this was very cool, Meteor didn’t end up catching on. My theory was that Meteor was too early and trying to do too much. And also React took over frontend dev.

Ever since Meteor fell from grace, I have wanted a better way of sharing code between the frontend and server but haven’t found a good way of doing it.

While code sharing is still a dream, at Luma we’ve figured out how to share types across the client and server.

Defining the Interface

We create Typescript interfaces for GET and POST requests. Each interface looks like this:

shared/get-api.ts

export type GetApi = {
  "/event/get": {
    request:{
      event_api_id: string;
    };
    response: {
      event: {api_id: string; name: string};
    };
  };
  ...
}

Then on the client we can do something like this:

client/api.ts

// This will use the GetApi to type the function inputs and outputs
public get = async <
  Path extends keyof GetApi,
  Request extends GetApi[Path]["request"],
  Response extends GetApi[Path]["response"]
>(
  path: Path,
  params?: Request,
  customHeaders?: any
): Promise<Response> => {
  return await this.request("get", path, params, customHeaders);
};

// this.request just wraps axios and does our auth logic for us

// using this
const {event} = api.get('/event/get', {event_api_id});

Then to type our Koa server requests:

server/router.ts

export const routerGet = <
  Path extends keyof GetApi,
  Request extends GetApi[Path]["request"],
  Response extends GetApi[Path]["response"]
>(
  path: Path,
  routeFunction: (
    ctx: RouterContext<Record<string, unknown>, IRouterContext>,
    requestData: StringifyValues<Request>
  ) => Promise<Response>
) => {
  router.get(path, async (ctx) => {
    const requestData = ctx.query;
    ctx.body = await routeFunction(
      ctx,
      requestData
    );
  });
};

// Then to define an actual route we do something like
routerGet("/event/get", async (ctx, {event_api_id}) => {
  // event_api_id is typed from GetAPI
  // and what we return is also typed! so we will get a type error if we don't return the right thing

  return {event: {api_id: 'victor', name: 'pontis'}}
})

(Btw, Koa is very similar to Express. So I expect this kind of thing would transfer over well to Express.)

Sharing Code with Yarn Workspaces

We structure our code in a mono-repo. Each folder corresponds to a different service — client/, server/, etc. But we store shared code in a special place, the shared/ folder.

The shared/ folder is compiled and shared across all of the services. We use the shared/ folder to store common utility functions and types.

This is where we define the API interface between the client and server.

Yarn Workspaces lets us easily share code without publishing to npm. We simply run yarn in our Dockerfile and the shared code just works.

Benefits

  • We get the benefits of type-checks across all of our API requests. When you change the signature of an API route, you can quickly see every other invocation that is affected.

  • This typechecking extends to the React components and server routes. We can even type our React components by API responses — const Component(props: PostApi[‘/route-name’][‘response’])

  • You can use code completion in the editor to auto-complete API routes. If you type in LumaClient.post you will see a list of suggested routes and payloads.

  • The typescript API serves as a tacit documentation for the API — one place to view all routes and their expected types.

Drawbacks

  • In order to keep the types simple, I banned all parametrized API routes. So we can’t do POST /events/<evt_api_id>/update. Instead, we do POST /events/update and pass in a the event ID. This doesn’t seem to hold us back but it feels non-standard.

  • Every time you add a new request, you have the added overhead of defining the API interface. I think this is clearly worth it, but there is definitely extra overhead.

  • The nice-ness of the API typing has let us be inconsistent with route and argument names. For example, we may do /event/list but also do /newsletters/list.