Table of contents
- Pre-requirements
- What are Type-Providers?
- Let's create the Fastify project with Typescript
- Project structure
- Contents of the package.json generated
- Let's install the package.json dependencies
- Default route root
- Let's add our route
- Typescript error
- Let's install the type-provider-typebox library
- Let's create the schema with Typebox
- Let's use the schema to generate the types for the route
- Refactoring
- Route handler type
In this article we will see how to automatically type Fastify routes via the JSON schema, using the Type-Provider: TypeBox.
Pre-requirements
Node.js v20.13.1
What are 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:
src/routes/root.ts
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