OVO Tech Blog

Living the type-safe, well-documented REST API dream with TypeScript + Fastify

Introduction

Laurie Boyes

Laurie Boyes

Bristol-based software developer


typescript fastify api documentation typebox nodejs

Living the type-safe, well-documented REST API dream with TypeScript + Fastify

Posted by Laurie Boyes on .
Featured

typescript fastify api documentation typebox nodejs

Living the type-safe, well-documented REST API dream with TypeScript + Fastify

Posted by Laurie Boyes on .


Tl;dr: An intro to writing REST APIs that combine TypeScript + Fastify web framework + some other neat projects for code, API documentation and schema validation that’s all kept in sync with static types. Here’s one I made earlier.

API Anxiety

Does your API documentation fill you with pride? Or do you paste new users the link with a flush of shame and a tumble of caveats?

Does your API even have documentation? Are you in a position of relying on new users being able and willing to read your code, or failing that, offering your own precious time to hand-hold each new user to get them going?

While we’re on the subject, how do your users find the errors returned by your API? Do they get a specific message telling them exactly what was wrong with their request so that they can fix it themselves? Or is your support channel filled with messages like ‘hey folks I get a 500 from your API can you tell me what’s up?’.

I’ve been there. But not anymore.

What’s my story

The Home Moves team (a proud division of the Orion Energy Platform) kicked off in January, and being a new team, we were fortunate enough to have the opportunity to spin up a tech stack from scratch. We decided we’d write our services in TypeScript and Node.js.

We’ve found a neat way to write APIs in TypeScript that allow us to define schema validation, API documentation, and user-input type safety with minimal fuss and minimal chance of parts of it going stale.

The old way

Whenever I’ve started writing a new Node.js API in the past, I’ve always reached for the express.js web framework. Before now I’ve always been mostly happy with how it turned out, but I’d be disappointed with a few aspects.

Documentation shmocumentation

Often, the importance of good API documentation would only occur to me after I’d moved on to other projects, or after the API had become complex enough that it was intimidating to start some.

If I was having an exceptionally professional moment and it did occur to me to establish a pattern for API documentation upfront, it would very quickly fall out of step with reality. I’d have to rely on my diligence alone to update the documentation file when I added a new field, for instance. When you can’t be confident that your documentation is accurate, what good is it really?

Request validation shmequest shmalidation

Even less often would it occur to me to add thorough request validation. For me, good request validation means getting a 4xx error with a clear explanation of what I’ve done wrong, instead of a 5xx error because the API cheerfully passed my request along through its inner workings and tripped up trying to call a string function on a number or something.

The new way

Setting down express.js in favour of fastify and adding a few choice open source projects has really made a difference.

Fastify web framework

Fastify is a relatively new web framework that’s heavily influenced by express.js. If its choice of name is anything to go by, it seems to consider its speed to be its best feature, but my favourite thing about it is its first class support for schema validation.

The shape of your route’s inputs can be defined in JSON schema, and as a result fastify will validate requests coming in, and give back a handy error message if they don’t match.

You’re free to reconfigure the shape of the validation error responses, but the default is pretty human-friendly. Here’s the response body I get back if I try and provide a string in a request body property called ‘count’ that I’ve specified should be a number:

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "body.count should be number"
}

This is great and all, but when I first tried it out I was irked that I’d still have to remember to maintain a schema alongside my real code. I could envision myself potentially getting into situations where maybe I’d change the type of a property in a request body but forget to update the schema, so users would get errors submitting valid requests.

But then, along came…

TypeBox schema builder

TypeBox describes itself as a ‘JSON Schema Type Builder with Static Type Resolution for TypeScript’. In my opinion it’s a real gem. There’s a neat gif at the top of its readme that demonstrates how it works.

The great thing it does for us is allows us to compose route schemas and static types with the same code.

Here’s an example. Without TypeBox I might define a request body schema like this:

{
  "type": "object",
  "properties": {
    "name": {
      "example": "My cool thing",
      "description": "The name of the thing",
      "type": "string"
    },
    "count": {
      "example": 123,
      "type": "number"
    },
    "coolEnough": {
      "type": "boolean"
    }
  },
  "required": [
    "name",
    "count"
  ]
}

I can define the same thing using TypeBox like this:

const requestBodySchema = Type.Object({
  name: Type.String({
    example: 'My cool thing',
    description: 'The name of the thing',
  }),
  count: Type.Number({ example: 123 }),
  coolEnough: Type.Optional(Type.Boolean()),
});

And then I can use TypeBox’s magical Static function to get hold of a matching object type

type RequestBody = Static<typeof requestBodySchema>;
 
// equivalent to:
//  type RequestBody = {
//    coolEnough?: boolean;
//    name: string;
//    count: number;
//  }

This is fantastic! We can apply this type to the request body and we’ll have type safety that we know is aligned with our schema validation.

Say for example our request body has a required property, and one day we decide we want to change this property to be optional. We’ll make the change to the TypeBox code, which will simultaneously apply the change to the schema and the type. Because the request body type has changed, TypeScript will remind us if there’s any part of the request-handling code that hasn’t been designed to cope with the potential for the property to not be there.

fastify-oas documentation generation plugin

I’ve not been using Fastify for long but I think I’ve already found my all-time favourite fastify plugin. Fastify-oas generates OpenAPI 3 documentation from the schemas already attached to your fastify routes, and serves up a swagger UI at a specified path. All you need to do is register the plugin. It really feels like magic.

Put it together and what have you got

So the documentation is defined by the schemas, which are defined by TypeBox, which also defines static types, so what you essentially have is water-tight type safety across your documentation, your request validation, and your request handling code.

For me at least, having been writing node APIs of varying quality for the last 5 years, this is a bit of a revelation.

Example code

Here’s an example of a pared down API that uses all this good stuff. You can run it yourself by checking out the github repo: https://github.com/laurieboyes/fastify-type-safe-api-example

import fastify, { FastifyRequest } from 'fastify';
import fastifyOas from 'fastify-oas';
import { Static, Type } from '@sinclair/typebox';

const app = fastify({ logger: true });

// Register fastify-oas so that any route we define from here onwards appears
// in the generated documentation
app.register(fastifyOas, {
  swagger: {
    info: {
      title: 'My cool API',
      version: '1.0.0',
    },
  },
  exposeRoute: true,
});

// Here we’re defining the schema for our request body using TypeBox
const requestBodySchema = Type.Object({
  name: Type.String({
    example: 'My cool thing',
    description: 'The name of the thing',
  }),
  count: Type.Number({ example: 123 }),
  coolEnough: Type.Optional(Type.Boolean()),
});

// We grab the type that TypeBox has created from our schema. We’ll be using
// this in the route handler code
type RequestBody = Static<typeof requestBodySchema>;

// We take a similar approach for our response schemas, except we have one
// per response code
const responseSchema = {
  200: Type.Object({
    thingId: Type.String({ example: 'abc' }),
  }),
};
type ResponseBody = Static<typeof responseSchema['200']>;

// Create our route, composing route schema and adding the route handling
// code
app.post(
  '/things',
  {
    schema: {
      body: requestBodySchema,
      response: responseSchema,
    },
  },
  async (
    // Do a bit of TypeScript fiddling with this parameter to override the
    // default request body type (any) with the one from our schema
    request: Omit<FastifyRequest, 'body'> & { body: RequestBody }
  ): Promise<ResponseBody> => {
    // Use the request body to do stuff
    app.log.info(`Got request to add thing called ${request.body.name}`);

    // ☝️ Just for kicks, try replacing the above line with the following
    // one. You’ll get a type error because we’ve not defined a ‘shame’ 
    // property in our request body schema ✨

    // app.log.info(`Got request to add thing called ${request.body.shame}`);

    return { thingId: 'xyz' };

    // ☝️ Just for kicks, try adding a new property to this returned object.
    // You’ll get another type error unless you also add it to the schema,
    // updating the request validation and api documentation ✨
  }
);

app.listen(3000).then(() => app.log.info(`server listening on 3000`));

The end

Thanks for reading. All credit of course goes to the maintainers of these great projects but all the same, if you end up using this for your own stuff, I’d love to hear about it 😁

Laurie Boyes

Laurie Boyes

http://blog.lrnk.co.uk/

Bristol-based software developer

View Comments...