Server
Type-Safety & Validation

Type-Safety & Validation

feTS uses JSON Schema (opens in a new tab) to describe the request parameters and the response body.

It allows us to have:

  • Type-safety: In the handler, complete type safety for the request parameters and the response body is ensured. You can use a tool like feTS Client that is capable of inferring these types from the router instance.
  • Request Validation: feTS validates the request parameters and if the request is invalid, it returns a 400 Bad Request. It uses AJV (opens in a new tab) for this purpose.
  • Safe and Fast Serialization: feTS uses the response schema to serialize the response body. Rather than JSON.stringify, it uses fast-json-stringify (opens in a new tab)which is twice as fast according to the benchmarks found here (opens in a new tab).

Setting Up Runtime Validations with AJV

To implement strict runtime validation with JSON schemas, if you're not utilizing Zod schemas, you'll need to configure the AJV plugin for feTS. AJV is opt-in since certain environments, such as Cloudflare Workers, aren't compatible with AJV.

import { createRouter, useAjv } from 'fets'
 
const router = createRouter({
  plugins: [useAjv()]
})

Request

You can type individual parts of the Request object including JSON body, form data, headers, query parameters, and URL parameters.

Headers

You can describe the shape of the headers using schemas.request.headers property. This is useful for validating API keys or authorization headers.

import { createRouter, Response } from 'fets'
 
const router = createRouter().route({
  method: 'post',
  path: '/todos',
  // Define the request body schema
  schemas: {
    request: {
      headers: {
        type: 'object',
        properties: {
          'x-api-key': { type: 'string' }
        },
        additionalProperties: false,
        required: ['x-api-key']
      }
    }
  } as const,
  handler: async request => {
    // This part is fully typed
    const apiKey = request.headers.get('x-api-key')
    // Would result in TypeScript compilation fail
    const wrongHeaderName = request.headers.get('x-api-key-wrong')
    // ...
    return Response.json({ message: 'ok' })
  }
})

Path Parameters

schemas.request.params can be used to validate the URL parameters like id inside /todos/:id:

import { createRouter, Response } from 'fets'
 
const router = createRouter().route({
  method: 'get',
  path: '/todos/:id',
  // Define the request body schema
  schemas: {
    request: {
      params: {
        type: 'object',
        properties: {
          id: { type: 'string' }
        },
        additionalProperties: false,
        required: ['id']
      }
    }
  } as const,
  handler: async request => {
    // This part is fully typed
    const { id } = request.params
    // ...
    return Response.json({ message: 'ok' })
  }
})

Query Parameters

Similar, for query parameters like /todos?limit=10&offset=0, we can use schemas.request.query to define the schema:

import { createRouter, Response } from 'fets'
 
const router = createRouter().addRoute({
  method: 'get',
  path: '/todos',
  // Define the request body schema
  schemas: {
    request: {
      query: {
        type: 'object',
        properties: {
          limit: { type: 'number' },
          offset: { type: 'number' }
        },
        additionalProperties: false,
        required: ['limit']
      },
    }
  } as const,
  handler: async request => {
    // This part is fully typed
    const { limit, offset } = request.query
    // You can also use `URLSearchParams` API
    const limit = request.parsedURL.searchParams.get('limit')
    // ...
    return Response.json({ message: 'ok' })
  }
})

JSON Body

We can also specify the JSON body schema by using schemas.request.json property:

import { createRouter, Response } from 'fets'
 
const router = createRouter().route({
  method: 'post',
  path: '/todos',
  // Define the request body schema
  schemas: {
    request: {
      json: {
        type: 'object',
        properties: {
          title: { type: 'string' },
          completed: { type: 'boolean' }
        },
        additionalProperties: false,
        required: ['title']
      }
    }
  } as const,
  handler: async request => {
    // This part is fully typed
    const { title, completed } = await request.json()
    // ...
    return Response.json({ message: 'ok' })
  }
})

Form Data / File Uploads

You can also type the request body as multipart/form-data or application/x-www-form-urlencoded which are typically used for file uploads.

We use type: string and format: binary to define a File object. The maxLength and minLength of JSON Schema can be used to limit the file size. Learn more in the OpenAPI docs (opens in a new tab).

import { createRouter, Response } from 'fets'
 
const router = createRouter().route({
  method: 'post',
  path: '/upload-image',
  // Define the request body schema
  schemas: {
    request: {
      formData: {
        type: 'object',
        properties: {
          title: { type: 'string' },
          completed: { type: 'boolean' },
          image: {
            type: 'string',
            format: 'binary',
            maxLength: 1024 * 1024 * 5 // 5MB
          }
        },
        additionalProperties: false,
        required: ['title']
      }
    }
  } as const,
  handler: async request => {
    // This part is fully typed
    const { title, completed, file } = await request.formData()
    // ...
    return Response.json({ message: 'ok' })
  }
})

Response (optional)

The response body can also be typed by the status code. It is highly recommended to explicitly define the status codes.

💡

If you don't define the response schema, feTS will still infer types from the handler's return value. However, in this case, you won't have response types in OpenAPI schema and runtime validation. For example, if you use OpenAPI schema with feTS Client, you won't have response typings. You will need to infer types directly from the router instead as shown here.

Example:

import { createRouter, Response } from 'fets'
 
const router = createRouter().addRoute({
  method: 'get',
  path: '/todos',
  // Define the request body schema
  schemas: {
    request: {
      headers: {
        type: 'object',
        properties: {
          'x-api-key': { type: 'string' }
        },
        additionalProperties: false,
        required: ['x-api-key']
      }
    },
    responses: {
      200: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            id: { type: 'string' },
            title: { type: 'string' },
            completed: { type: 'boolean' }
          },
          additionalProperties: false,
          required: ['id', 'title', 'completed']
        }
      },
      401: {
        type: 'object',
        properties: {
          message: { type: 'string' }
        },
        additionalProperties: false,
        required: ['message']
      }
    }
  } as const,
  handler: async request => {
    const apiKey = request.headers.get('x-api-key')
    if (!apiKey) {
      return Response.json(
        { message: 'API key is required' },
        {
          status: 401
        }
      )
    }
    const todos = await getTodos({
      apiKey
    })
    // This part is fully typed
    return Response.json(todos)
  }
})