Fastify: Type safe with Type-Providers

Fastify: Type safe with Type-Providers

In this article we will see how to automatically type Fastify routes via the JSON schema, using the Type-Provider: TypeBox.

click here to read the article in italian language

Pre-requirements

Node.js v20.13.1

What are Type-Providers?

Documentation: Type-Providers

Type-Providers are a feature only for Fastify projects that use Typescript as a language.

There are different types of Type-Providers, for this article, we will use: TypeBox.

Let's create the Fastify project with Typescript

We open the terminal and type this command:

npx fastify-cli generate my-app --lang=ts

This command will generate a Fastify project with Typescript, using the utility fastify-cli.

Project structure

At this point, we have a project with the following structure:

Contents of the package.json generated

{
  "name": "my-app",
  "version": "1.0.0",
  "description": "This project was bootstrapped with Fastify-CLI.",
  "main": "app.ts",
  "directories": {
    "test": "test"
  },
  "scripts": {
    "test": "npm run build:ts && tsc -p test/tsconfig.json && c8 node --test -r ts-node/register \"test/**/*.ts\"",
    "start": "npm run build:ts && fastify start -l info dist/app.js",
    "build:ts": "tsc",
    "watch:ts": "tsc -w",
    "dev": "npm run build:ts && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch:ts\" \"npm:dev:start\"",
    "dev:start": "fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@fastify/autoload": "^5.0.0",
    "@fastify/sensible": "^5.0.0",
    "@fastify/type-provider-typebox": "^4.0.0",
    "fastify": "^4.26.1",
    "fastify-cli": "^6.2.1",
    "fastify-plugin": "^4.0.0"
  },
  "devDependencies": {
    "@types/node": "^20.4.4",
    "c8": "^9.0.0",
    "concurrently": "^8.2.2",
    "fastify-tsconfig": "^2.0.0",
    "ts-node": "^10.4.0",
    "typescript": "^5.2.2"
  }
}

Let's install the package.json dependencies

Enter the folder of the newly created "my-app" project and install all the dependencies of the package.json with this command:

npm install

Default route root

fastify-cli has already created 2 routes for us, we can find them inside the files:

  1. src/routes/root.ts

  2. src/routes/example/index.ts

Let's see the generated code for the root route:

import { FastifyPluginAsync } from "fastify"

const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.get('/', async function (request, reply) {
    return 'this is an example'
  })
}

export default example;

This route defines an HTTP GET call that returns a response string: 'this is an example'.

Let's add our route

To make an example as simple as possible, let's add a route that takes text as input and returns the text in uppercase as a response.

import { FastifyPluginAsync } from "fastify"

const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {

  fastify.get('/', async function (request, reply) {
    return 'this is an example'
  })

  fastify.post('/uppercase', async function (request, reply) {
    const body = request.body
    const text = body.text
    return { textResult: text.toUpperCase()} 
  })

}

export default example;

We have added a POST type route, the route expects a single 'text' property in the body and returns an object with a single 'textResult' property.

Typescript error

At this point Typescript will generate an error because initially, the body object is of type unknown, so we need to type the body object.

We could type the body object directly with a Typescript interface. Still, since we will be using a schema to validate the route, we will let Typescript generate the types directly from our schema, to have a single source of truth.

Let's install the type-provider-typebox library

npm i @fastify/type-provider-typebox

Let's create the schema with Typebox

import { Type } from '@fastify/type-provider-typebox'
import { FastifyPluginAsync } from "fastify"

const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {

  fastify.get('/', async function (request, reply) {
    return 'this is an example'
  })

  fastify.post('/uppercase', {
    schema: {
      body: Type.Object({
        text: Type.String(),
      }),
      response: {
        200: Type.Object({ 
          textResult: Type.String() 
        }),
      }
    }
  }, async function (request, reply) {
    const body = request.body
    const text = body.text
    return { textResult: text.toUpperCase()} 
  })

}

export default example;

Now we have created a schema that defines that:

  • If the customer does not pass exactly one object with a string-type 'text' property in the body, he will receive a 404 error

  • If the server does not return exactly one object with the string-type 'textResult' property inside, the client will receive a 500 error

But there is still a problem, the schema is right but Typescript still doesn't compile because request.body is still of type unknown.

Let's use the schema to generate the types for the route

import { Type, FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'

const example: FastifyPluginAsyncTypebox = async (fastify, opts): Promise<void> => {

  fastify.get('/', async function (request, reply) {
    return 'this is an example'
  })

  fastify.post('/uppercase', {
    schema: {
      body: Type.Object({
        text: Type.String(),
      }),
      response: {
        200: Type.Object({ 
          textResult: Type.String() 
        }),
      }
    }
  }, async function (request, reply) {
    const body = request.body
    const text = body.text
    return { textResult: text.toUpperCase()} 
  })

}

export default example;

We just replaced the type:

  • FastifyPluginAsync (type for plugin imported directly from 'fastify')

With the type:

  • FastifyPluginAsyncTypebox (type for plugin imported from '@fastify/type-provider-typebox')

Refactoring

Now everything works, but personally, for better readability, I prefer to separate the function of the route and the schema from the declaration of the route itself.

import { Type, FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
import { FastifyReply, FastifyRequest } from 'fastify'

const example: FastifyPluginAsyncTypebox = async (fastify, opts): Promise<void> => {

  fastify.get('/', async function (request, reply) {
    return 'this is an example'
  })

  const schemaPostUppercase = {
    body: Type.Object({
      text: Type.String(),
    }),
    response: {
      200: Type.Object({ 
        textResult: Type.String() 
      }),
    }
  }

  async function postUppercase(request: FastifyRequest, reply: FastifyReply) {
    const body = request.body
    const text = body.text
    return { textResult: text.toUpperCase()} 
  }

  fastify.post('/uppercase', { schema: schemaPostUppercase }, postUppercase)

}

export default example;

This way we separated the schema into the variable: schemaPostUppercase and the route handler into the function: postUppercase.

But now Typescript is no longer able to type the body and return of the function and generates the error:

And also note that the return is no longer typed. Typescript allows us to do this:

return { hello: text.toUpperCase()}

Returning the 'hello' property should generate a Typescript compilation error because it does not respect the schema which instead defines that only the 'textResult' property is present in the response.

This is because the route no longer takes types from the schema.

Route handler type

import { Type, FastifyPluginAsyncTypebox, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import { ContextConfigDefault, FastifyBaseLogger, FastifyInstance, FastifyReply, FastifyRequest, FastifySchema, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault, RouteGenericInterface } from 'fastify'
import { ResolveFastifyReplyReturnType } from 'fastify/types/type-provider'

type FastifyInstanceTypebox = FastifyInstance<
  RawServerDefault,
  RawRequestDefaultExpression<RawServerDefault>,
  RawReplyDefaultExpression,
  FastifyBaseLogger,
  TypeBoxTypeProvider
>

type FastifyRequestTypebox<TSchema extends FastifySchema> = FastifyRequest<
  RouteGenericInterface,
  RawServerDefault,
  RawRequestDefaultExpression<RawServerDefault>,
  TSchema,
  TypeBoxTypeProvider
>

type FastifyReplyTypebox<TSchema extends FastifySchema> = FastifyReply<
  RawServerDefault,
  RawRequestDefaultExpression,
  RawReplyDefaultExpression,
  RouteGenericInterface,
  ContextConfigDefault,
  TSchema,
  TypeBoxTypeProvider
>

export type RouteHandlerTypebox<TSchema extends FastifySchema> = (
  this: FastifyInstanceTypebox,
  request: FastifyRequestTypebox<TSchema>,
  reply: FastifyReplyTypebox<TSchema>
) => ResolveFastifyReplyReturnType<TypeBoxTypeProvider, TSchema, RouteGenericInterface>

const example: FastifyPluginAsyncTypebox = async (fastify, opts): Promise<void> => {

  fastify.get('/', async function (request, reply) {
    return 'this is an example'
  })

  const schemaPostUppercase = {
    body: Type.Object({
      text: Type.String(),
    }),
    response: {
      200: Type.Object({ 
        textResult: Type.String() 
      }),
    }
  }

  const postUppercase: RouteHandlerTypebox<typeof schemaPostUppercase> = async function (request, reply) {
    const body = request.body
    const text = body.text
    return { textResult: text.toUpperCase()} 
  }

  fastify.post('/uppercase', { schema: schemaPostUppercase }, postUppercase)

}

export default example;

Unfortunately, in order to separate the declaration of the 'postUppercase' function from the route definition, we have to create its type manually.

For simplicity, I have defined all the types within the same file, but in a real project, you generally define the types in a separate file and import them into the files in which they are used.

Let's see this process step by step:

  • We transformed the postUppercase function from this:
async function postUppercase(request: FastifyRequest, reply: FastifyReply) {
    const body = request.body
    const text = body.text
    return { textResult: text.toUpperCase()} 
}
  • To this:
const postUppercase: RouteHandlerTypebox<typeof schemaPostUppercase> = async function (request, reply) {
    const body = request.body
    const text = body.text
    return { textResult: text.toUpperCase()} 
}

postUppercase becomes from a function to a variable that contains an anonymous function, this syntax allows us to define the types for the parameters, the return, and the context (this) of the function with a single type:

RouteHandlerTypebox<typeof schemaPostUppercase>

Let's see its definition:

export type RouteHandlerTypebox<TSchema extends FastifySchema> = (
  this: FastifyInstanceTypebox,
  request: FastifyRequestTypebox<TSchema>,
  reply: FastifyReplyTypebox<TSchema>
) => ResolveFastifyReplyReturnType<TypeBoxTypeProvider, TSchema, RouteGenericInterface>

In the RouteHandlerTypebox type, we are going to define types for:

  • this: the context of the function i.e. when you want to access the 'fastify' object via 'this'

  • request: the first parameter of the function

  • reply: the second parameter of the function

  • function return: ResolveFastifyReplyReturnType

Manually typing with the schema: request, reply, and the return of the function, which takes the generic type: TSchema.

Manuel Salinardi