Better Backend DX: JSON Schema + TypeScript + Swagger = ✨ Vol. 1
Tags: typescript, webdev, api, dx
Published: 2022-05-17 14:48:03

Hi! I'm David Peng👋. You can find me on Twitter: [@davipon](https://twitter.com/davipon).

Welcome to Part 2 of the **Better Backend DX** series.

This time we're going to build a REST API (yet another REST API tutorial 😅) using the starter template from the previous post: [Better Backend DX: Fastify + ESBuild = ⚡️](https://davipon.hashnode.dev/better-backend-dx-fastify-esbuild), and leveraging **JSON Schema** & **TypeScript** to:

1. Validate routes & serialize outputs
2. Enable VS Code IntelliSense for route's **Request** & **Reply**
3. Automatically generate [OpenAPI](https://swagger.io/specification) schemas and serve a [Swagger UI](https://swagger.io/tools/swagger-ui/)
4. Improve code reusability & testability
5. Improve DX, for sure 😉

Before we start, I like to share a tweet about DX that resonated with me:

{% embed https://twitter.com/housecor/status/1524384220776484864 %}

This series might cover some, but it's great to keep in mind and review your DX metrics regularly.


## What is JSON Schema?

[JSON Schema](https://json-schema.org) is a vocabulary that allows you to **annotate** and **validate** JSON documents.

Here's an example, let's say we have a JSON object like this:
```JSON
{
  "hello": "world"
}
```
We can ensure the value of `"hello"` is a `string` type by declaring a JSON Schema:
```JSON
{
  "type": "object",
  "properties": {
    "hello": { "type": "string" }
  }
}
```

In Fastify, it's recommended to use JSON Schema to validate your routes and serialize your outputs.

### Why do we validate routes?

Assume we're developing a blog post REST API. We want to get posts that haven't been deleted:

```HTTP
# General GET method
GET http://localhost:3000/posts HTTP/1.1

# Get method with a filter in the query string (/posts?deleted=[boolean])
GET http://localhost:3000/posts?deleted=false HTTP/1.1
```

It should return a JSON with an array of posts:

```JSON
{
  "posts": [
    {
      "title": "...",
      ...
      "deleted": false
    },
    ...
  ]
}
```
In this case, query string `deleted` should only accept `boolean`, but what if the API user accidentally used `string`?

```HTTP
# The Wrong type of query string "deleted"
GET http://localhost:3000/posts?deleted=abcde HTTP/1.1
```

If we don't validate our route, the user may still receive a response, but it's a **false-positive**:

```JSON
{
  "posts": []
}
```

As an API designer, we need to provide meaningful insights to the downstream consumer if schema validation fails for a request, like a proper error message:

```JSON
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "querystring.deleted should be boolean"
}
```

### Why do we serialize outputs?

Fastify encourages users to use an output schema, as it can drastically increase throughput and help prevent accidental disclosure of sensitive information.

> I recommend reading the Fastify documentation [Validation and Serialization](https://www.fastify.io/docs/latest/Reference/Validation-and-Serialization/) for more details.

### How to use JSON Schema in Fastify?

Take the blog post API for instance:

```ts
// src/routes/posts.ts
import { FastifyInstance } from 'fastify'
import { posts } from './posts'

export default async (fastify: FastifyInstance) => {

  const querySchema = {
    type: 'object',
    properties: {
      deleted: { type: 'boolean' }
    }
  }
  /*
    fastify.get(path, [options], handler)
  */
  fastify.get(
    '/', // GET http://localhost:3000/posts HTTP/1.1
    {
      // Validate Query String Schema: /posts?deleted=[boolean]
      schema: {
        querystring: querySchema
      }
    },
    async function (req, reply) {
      /*
        req.query would look like this:
        {
          "deleted": true | false
        }
      */
     const { deleted } = req.query
      if (deleted !== undefined) {
        const filteredPosts = posts.filter((post) => post.deleted === deleted)
        reply.send({ posts: filteredPosts })
      } else reply.send({ posts }) // return all posts if no "deleted" query string
    }
  )
}
```

Fastify can now validate the querystring by passing `schema` to [routes options](https://www.fastify.io/docs/latest/Reference/Routes/#routes-options).

But there is one problem if you're using TypeScript: VS Code would shout at you that `deleted` doesn't exist on type '{}':

![type error](https://drive.google.com/uc?id=1981cEIObK02Rm7GMkVyNVl22obBb0n3x)

Emm 🧐, maybe just create a new type for `req.query`?

```ts
type postQuery = {
  deleted: boolean
}
...
// Works like a charm 🥳
const { deleted } = req.query as postQuery
```

Yeah, it works. We declare a JSON Schema to validate routes and then declare a type for static type checking.

```ts
const querySchema = {
  type: 'object',
  properties: {
    deleted: { type: 'boolean' }
  }
}

type postQuery = {
  deleted: boolean
}
```

Both objects carry similar if not exactly the same information. This code duplication can annoy developers and introduce bugs if not properly maintained.

![Nonono](https://media4.giphy.com/media/YGqUnNrHTKud16HvkN/giphy.gif?cid=ecf05e479aq01q7kf4nv53ocpi0cu2lausq4z6aa30k0qeds&rid=giphy.gif&ct=g align="center")

> Keep typing twice is just like forcing Uncle Roger to watch more of Jamie Oliver's video.

## Stop typing twice 🙅‍♂️

[`json-schema-to-ts`](https://github.com/ThomasAribart/json-schema-to-ts) comes to the rescue. 💪

It's a library that helps you infer TS types directly from JSON schemas:

```ts
import { FromSchema } from 'json-schema-to-ts'

const querySchema = {
  type: 'object',
  properties: {
    deleted: { type: 'boolean' }
  }
} as const

type postQuery = FromSchema<typeof querySchema>
// => Will infer the same type as above
```

Here are some [benefits](https://github.com/ThomasAribart/json-schema-to-ts#why-use-json-schema-to-ts) to use this library:
- ✅  **Schema validation**: `FromSchema` raises TS errors on invalid schemas, based on DefinitelyTyped's definitions
- ✨  **No impact on compiled code**: `JSON-schema-to-ts` only operates in type space. And after all, what's lighter than a dev-dependency?
- 🍸  **DRYness**: Less code means less embarrassing typos
- 🤝  **Real-time consistency**: See that `string` that you used instead of an `enum`? Or this `additionalProperties` you confused with `additionalItems`? Or forgot entirely? Well, `JSON-schema-to-ts` does!
- and more!

Let's update the route:

```ts
// src/routes/posts.ts
import { FastifyInstance } from 'fastify'
import { FromSchema } from 'json-schema-to-ts'
import { posts } from './posts'

export default async (fastify: FastifyInstance) => {

  const querySchema = {
    type: 'object',
    properties: {
      deleted: { type: 'boolean' }
    }
  } as const

  type postQuery = FromSchema<typeof querySchema>

  /*
    The shorthand route methods (i.e., .get) accept a generic object
    "RouteGenericInterface" containing five named properties:
    Body, Querystring, Params, Headers, and Reply.
    The interfaces Body, Querystring, Params, and Headers will be passed down
    through the route method into the route method handler request instance and
    the Reply interface to the reply instance.
  */

  fastify.get<{ Querystring: postQuery }>(
    '/',
    {
      schema: {
        querystring: querySchema
      }
    },
    async function (req, reply) {
     const { deleted } = req.query
      if (deleted !== undefined) {
        const filteredPosts = posts.filter((post) => post.deleted === deleted)
        reply.send({ posts: filteredPosts })
      } else reply.send({ posts })
    }
  )
}
```

![hurray](https://drive.google.com/uc?id=1U6Yat_CquRXorqAbhwuUFO0nvmSZWna1)

Great! Now we can declare JSON Schemas to validate routes and infer types from them. `JSON-schema-to-ts` can raise TS error on invalid schemas and enable intelligent code completion of the request or reply instance in the handler function.

![yes!](https://media1.giphy.com/media/3pGSvfkM4SN6icLRWa/giphy.gif?cid=ecf05e470x8lustwnwv3o86b9yrf3xlo4gffq2oshzfe8ojb&rid=giphy.gif&ct=g align="center")


> Most of these goodnesses happen during development so that we can catch bugs or problems as early as possible. The shorter feedback loop results in a much better DX.

## API Documentation: Swagger UI

![swagger](https://static1.smartbear.co/swagger/media/assets/images/swagger_logo.svg)

> [Swagger UI](https://swagger.io/tools/swagger-ui/) allows anyone — be it your development team or your end consumers — to visualize and interact with the API's resources without having any of the implementation logic in place.![swagger ui](https://static1.smartbear.co/swagger/media/images/tools/opensource/swagger_ui.png?ext=.png)

When it comes to DX, it's not just my and my co-workers' experience but also our end consumers' DX. API documentation and standard OpenAPI specification can help your user in many ways.

I'd recommend watching this webinar by Swagger: [API Developer Experience: Why it Matters, and How Documenting Your API with Swagger Can Help](https://swagger.io/blog/api-documentation/api-documentation-and-developer-experience/)

While documenting can be a tedious and time-consuming task, and that's why we need `@fastify/swagger` to generate docs automatically.

### OpenAPI Documentation Generator

[`@fastify/swagger`](https://github.com/fastify/fastify-swagger) is a fastify plugin to serve a Swagger UI, using Swagger (OpenAPI v2) or OpenAPI v3 schemas automatically generated from **your route schemas**, or from an existing Swagger/OpenAPI schema.

In other words, we can easily generate the doc and serve a Swagger UI because we'd already had route schemas! Let's do it:

```bash
pnpm add @fastify/swagger
```

Configure the plugin (I'm using OpenAPI v3):

```ts
// src/plugins/swagger.ts
import fp from 'fastify-plugin'
import swagger, { FastifyDynamicSwaggerOptions } from '@fastify/swagger'

export default fp<FastifyDynamicSwaggerOptions>(async (fastify, opts) => {
  fastify.register(swagger, {
    openapi: {
      info: {
        title: 'Fastify REST API',
        description: 'Use JSON Schema & TypeScript for better DX',
        version: '0.1.0'
      },
      servers: [
        {
          URL: 'http://localhost'
        }
      ]
    }
    exposeRoute: true
  })
})
```

Once you start the server, go to `http://127.0.0.1:3000/documentation`, and you'll see the Swagger UI:

![swagger ui](https://drive.google.com/uc?id=1nTfpYdu8PlhdfJGsTis86yE7CYUyCMrz)

Fantastic! By adding `@fastify/swagger`, we don't need to hand-code API specifications in `YAML` or `JSON`. Simply declare route schemas, and the doc will be generated automatically.

## That's the Vol.1 of JSON Schema + Typescript = ✨

OK. I know we haven't touched the real coding part 😅. They'll be in Vol. 2.

The goal of the Vol. 1 is to give you a taste of the goodness of JSON Schema and how to cope with always-shouting TS errors properly.

I'm going to cover these in the Vol. 2:

1. Improve code reusability & testability by separating `options` and `handler` of route method
2. Use of JSON Schema `$ref` keyword
3. Use Thunder Client (VS Code extension) to test APIs

Kindly leave your comment and thoughts below!

See you in Vol. 2!