Building a Fastify Typescript REST API with type-safe runtime validation

Cover Image for Building a Fastify Typescript REST API with type-safe runtime validation
Sam Cook
Sam Cook

Node.js and Typescript are industry leading technologies. There are plenty of applications built using Node everyday. However, we have noticed that building a type-safe API which uses the same set of types for compile-time and runtime validation can be a challenge for a lot of organisations. This guide aims to prove that managing these types in one place with no duplication is possible, as long as we opt for the right building blocks for our applications.

Requirements

  • Node.js 16 or higher

Chosen technologies

  • Typescript — A fully typed superset of Javascript

  • Fastify — Highly performant API framework with built in validation support

  • Typebox — JSON Schema Type Builder with Static Type Resolution for TypeScript

Step 1 — Initialise your project and dependencies

  1. Create your project folder and generate a blank package.json:

mkdir typescript-rest-api
cd typescript-rest-api
npm init -y

2. Install the relevant dependencies and their associated types:

npm i fastify @fastify/type-provider-typebox @sinclair/typebox
npm i -D typescript @types/node ts-node nodemon

3. Generate a tsconfig.json file:

npx tsc --init

4. Add some commands to your package.json to run the project:

{
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node index.js",
    "dev": "nodemon index.ts"
  }
}

Step 2 — Get your API running

Next, we want to get a very simple iteration of our API running in watch mode. This is a quick way to verify that our initial setup is correct. We will move onto adding validation afterwards.

  1. Create a file called index.ts in the root of the repo

  2. Add the following lines to the file:

import fastify from 'fastify'

const server = fastify()

server.post('/foo', async (request: any, reply) => {
  const  { bar, baz } = request.body;
  return reply.status(200).send(`You sent a request which contained ${bar} ${baz}!`);
})

server.listen({ port: 8080 }, (err, address) => {
  if (err) {
    console.error(err)
    process.exit(1)
  }
  console.log(`Server listening at ${address}`)
})

Note: Notice above that we are marking request as an any. This is a cardinal sin in Typescript development. We will amend this shortly once we have verified our server is running correctly.

3. Execute the npm run dev command

4. Check the terminal output, you should see a message saying: Server listening at localhost:8080

5. POST to the /foo route:

curl --location --request POST 'localhost:8080/foo' \
--header 'Content-Type: application/json' \
--data-raw '{
    "bar": true,
    "baz": 123
}'

Check that you receive the You sent a request which contained true 123! Now we have verified that the API is receiving our requests and parsing their bodies.

Step 3 — Spice up your API with compile-time and runtime type checking 🌶🔥

If we wanted to, we could stop at Step 2. Our API is receiving data and we can clearly read it. However, what would we do if someone sent a number instead of a string, or a boolean in the place of an object we expect? Boom. Any code that relies on the request body being in a specific schema (pretty much EVERY line after our route handler) will break if our request is malformed.We need to validate the request body at runtime, and wouldn’t it be nice to know the property types when we are developing the application?Thankfully, Fastify has some incredible integrations which will make this quite straightforward using the Typebox plugin.

Typescript 🤝 Fastify 🤝 Typebox = 😎

  1. Configure Fastify to understand Typebox typings and return all validation errors in one response:

import fastify from "fastify";
import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox";
import { Static, Type } from "@sinclair/typebox";

const server = fastify({
  ajv: {
    customOptions: {
      allErrors: true,
    },
  },
}).withTypeProvider<TypeBoxTypeProvider>();

2. Define a Typebox object for our request body and cast it to a static type that Typescript can understand:

const Body = Type.Object({
  bar: Type.Boolean(),
  baz: Type.Number(),
});
type IBody = Static<typeof Body>;

Note: It’s good practice to define your types in a separate file away from any API specific code, such as in a types folder. This means that they can be managed and published separately if they need to be. For ease of use in this tutorial, we are writing them inline.

3. Tell Fastify:

  • To use our Typebox body type to validate the request body at runtime

  • What the expected type of the request.body object will be at compile time

server.post<{
  Body: IBody;
}>(
  "/foo",
  {
    schema: {
      body: Body,
    },
  },
  (request, reply) => {
    // The request body is now fully typed!
    const  { bar, baz } = request.body;

    return reply.status(200).send(`You sent a request which contained ${bar} ${baz}!`);
  }
);

Once we piece the above lines together, we are left with this:

import fastify from "fastify";
import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox";
import { Static, Type } from "@sinclair/typebox";

const server = fastify({
  ajv: {
    customOptions: {
      allErrors: true,
    },
  },
}).withTypeProvider<TypeBoxTypeProvider>();

const Body = Type.Object({
  bar: Type.Boolean(),
  baz: Type.Number(),
});

type IBody = Static<typeof Body>;

server.post<{
  Body: IBody;
}>(
  "/foo",
  {
    schema: {
      body: Body,
    },
  },
  (request, reply) => {
    // The request body is now fully typed
    const  { bar, baz } = request.body;

    return reply.status(200).send(`You sent a request which contained ${bar} ${baz}!`);
  }
);

server.listen({ port: 8080 }, (err, address) => {
  if (err) {
    console.error(err);
    process.exit(1);
  }
  console.log(`Server listening at ${address}`);
});

Now, when we make a request with an invalid body, we will receive a validation error as a response:

We also know the expected types of our request body while developing, which is a huge benefit of using Typescript:

Summary

In the above tutorial, we have built a type-safe API which uses a single source of truth for both the runtime AND compile time validation. This will drastically reduce the chances of your API accepting and using invalid data.This is a very simple but sturdy foundation to build your systems on. This is a great base we use for a large number of our REST API projects at Logicful.You can take this further by using Typebox and Fastify to also validate query parameters and response bodies as well.

Learning resources

If you have any immediate or future software consultancy needs and want to learn how Logicful ⚛ can help you, get in touch.


More Stories

Cover Image for The Gymshark black Friday sale - A lesson in software resiliency and best practices

The Gymshark black Friday sale - A lesson in software resiliency and best practices

In the realm of e-commerce, Gymshark's Black Friday sale has become a noteworthy case study, shedding light on the intricacies of handling a high-traffic website during peak periods.

Sam Cook
Sam Cook
Cover Image for Creating a better technical interview for hiring great engineers

Creating a better technical interview for hiring great engineers

As engineers, we know that technical interviews can be nerve-wracking and sometimes feel very detached from real-world scenarios. The key to creating a reliable technical interview is to try and simulate the reality of the actual role as much as possible.

Sam Cook
Sam Cook