← Back
In Depth

Leveling Up our APIs with TypeScript

Adam Vartanian, Engineering Manager at CordAdam Vartanian
  • Engineering
TypeScript

Here at Cord, we love TypeScript. It helps us catch errors, refactor code, and generally makes life more pleasant.

We also love APIs. Our product is chock full of them: a suite of REST APIs, an in-browser SDK full of JavaScript APIs, outgoing webhooks, React components, web components, and more!

But one side effect of having a lot of APIs is that when we add new features, there are a lot of places that need to be updated, and doing that is a lot of work. If we add a new property to a thread, for instance, there are more than a dozen different places that need to be updated.

  • REST APIs: Create thread, update thread, send message, get thread, list threads
  • JavaScript APIs: Create thread, update thread, send message, get thread, list threads
  • Outgoing webhook: Message sent
  • Web component events
  • React component event handlers
  • Command line arguments to our CLI
  • Documentation for all of the above

Doing that is a big drag on our productivity, but applying a bit of TypeScript magic can let us get some of that productivity back by alerting us to where changes are needed, streamlining the process of making those, and even generating things so we don’t need to write them ourselves.

I’m going to walk through the different levels of TypeScript complexity we’ve gone through, starting with just having types and ending with autogenerating our docs.

Level 1: Have Types

Before we did anything fancy with types, we had to have some types to begin with. These are especially useful for our customers calling our APIs, which is why we publish a package with all our type definitions. The basic types are simple to write out, but often we have a number of different related types for each kind of object in our system, which can be a pain to maintain by hand.

For example, one of the central objects in Cord is a thread, which is a collection of messages, so of course we have a type to represent a thread. But it’s not just one type, because there are lots of ways of interacting with a thread, and the data that’s relevant is different for each. For example:

  • Some data is read-only, like the convenience property for how many messages are in the thread (you update that by sending a message), so it should only be present on the types for reading a thread, not the ones used to update it
  • Some data is required when creating something but optional when updating it, like the content of a message
  • Some data isn’t available in every API, like the ID of the group that owns the thread. That can only be changed via the administrative REST API, not the in-browser JS API.

Because of this, we created a collection of related thread types, one for each use site.

We took advantage of TypeScript’s utility types to reduce the number of changes we need to make. So for example, here’s a simplified version of our thread types:

TypeScript
type CoreThreadData = {
  id: ThreadID;
  groupID: GroupID;
  location: Location;
  metadata: EntityMetadata;
  subscribers: UserID[];
  userMessages: number;
};

type ServerCreateThread = Pick<CoreThreadData, 'location' | 'groupID'> &
  Partial<
    Omit<
      CoreThreadData,
      'location' | 'groupID' | 'subscribers' | 'userMessages'
    >
  > & {
    addSubscribers?: UserID[];
  };

type ServerUpdateThread = Partial<Omit<ServerCreateThread, 'id'>>;

type ClientCreateThread = ServerCreateThread;

type ClientUpdateThread = Partial<
  Omit<ClientCreateThread, 'id' | 'location' | 'groupID'>
>;

In order to add a new field to threads, we just need to add it to CoreThreadData and it will flow through to all the other types automatically.

Level 2: Checking JSON Return Types

One of the easiest wins was checking that we were sending what we said we would from our REST APIs and webhooks. Using Express, it’s easy to return values that aren’t what you promised, since by default Express doesn’t know what type you intend to send. We had a couple bugs where we added new fields to types but didn’t update every place that sent that type, since we had a lot of code that looked like this:

TypeScript
response.json({ id, name, shortName, metadata });

We fixed this by introducing a local variable that let TypeScript tell us if we forget a required value:

TypeScript
const result: ServerGetUser = { id, name, shortName, metadata };
response.json(result);

Alternatively, we could have told Express what return type to expect:

TypeScript
async function getUserHandler(request: Request, response: Response<ServerGetUser>) {
  // ...
  response.json({ id, name, shortName, metadata });
}

We didn’t use that version in our code, since we couldn’t reuse it for outgoing webhooks, but it might work well for other codebases.

Level 3: Validating Inputs

The flip side of checking our return values is checking that our callers have sent us the right values. Here we need to be careful: callers can send us literally anything, so it’s not enough to just use a type assertion to get TypeScript to treat it as the right type, we need to actually check that it’s structurally valid.

We started out by hand-writing validation code, but it was error prone, and it added yet another place we needed to update every time anything changed.

So we switched to generating a JSON schema from our type definitions using typescript-json-schema and then using ajv to validate inputs against it. Our JSON schema objects are named the same as our types, so we ended up with a validation library that lets us check that an input matches a type, such as ServerUpdateUser, with code that looks like:

TypeScript
const { name, shortName, metadata } = validate.ServerUpdateUser(request.body);

This ensures our errors are handled consistently, our inputs are what we expect them to be, and requires no effort to update besides rerunning the schema generation. (And our CI can validate that you didn’t forget!)

Level 4: Confirming Use of Inputs

The eagle-eyed among you may have noticed a potential flaw in the validation code above. If we add a new property to ServerUpdateUser, the validation code will automatically start to validate it, but the code that uses that type won’t do anything with the new field. Indeed, we’ve had several bugs of that sort happen.

To drive the point home, the actual ServerUpdateUser has a status field as well. Just looking at the code, it’s easy to miss that we failed to handle that field until someone tries to update a user’s status and reports the bug.

Ideally we’d catch this in code review or tests, but this is exactly the kind of bug that can sneak through those. These files probably wouldn’t be edited at all in the change that added this feature, and all the existing tests would continue to pass, so the code reviewer would need to be particularly vigilant to spot the omission.

But there is a solution! We use object destructuring with a rest property to check that we didn’t leave anything out.

TypeScript
const { name, shortName, metadata, ...rest } = validate.ServerUpdateUser(request.body);
const _: Record<string, never> = rest;

By explicitly typing the throwaway _ variable as Record<string, never>, we ask TypeScript to confirm that there wasn’t anything left over: the only value for rest that makes this assignment legal is the empty object. Like with level 2, this makes TypeScript fail if we make a change in the types but forget to update the use. And as a side benefit, when we add a new property, just running the build is enough to point us to all the places that need to be updated to use it.

Level 5: Generating Docs

At this point, we had streamlined most of the code changes that were needed to handle changes to our APIs, but documenting those changes still required a lot of work. Since we already had a lot of that information expressed in the types, and those types have documentation comments, why couldn’t we use those to generate our documentation? Turns out, we could!

We started out generating our docs from the JSON schema we produced with typescript-json-schema, but we found that wasn’t quite good enough:

  • TJS doesn’t produce results for anything other than type declarations, but we wanted to document other things, like function signatures
  • JSON schema uses very simple types (like object), but our types have useful names we wanted to preserve
  • Types that referenced other types were awkward to use, because they referenced type definitions that could live an arbitrary distance away in the JSON schema

Luckily, the TypeScript compiler has an API itself, so we could programmatically build our type definitions and then extract their details in exactly the format we needed. And that was handy, because we ran into lots of little customizations we wanted in exactly how we displayed our types, which we could make because we controlled the generator code. For example, our API functions have an example in their TSDoc, and we extract those and display them specially in our docs pages.

A walkthrough of our docs generator would make this post even longer, so that will have to wait for another day, but this change has saved us a huge amount of work on our docs site, where almost all of the API interfaces are generated directly from the type definitions.

Mission Complete

We’re very happy with how our infrastructure has made it easier and more reliable to ship API updates.

I hope this has given you some ideas for how you can take advantage of TypeScript to improve your own projects! If you have your own TypeScript tricks you adore, we’d love to hear about them.

And if you need to add chat, comments, or notifications to your own project, take a look at our many APIs and see if Cord is what you need!