ServerType-Safety & Validation

Type-Safety & Validation

feTS uses JSON Schema 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.
  • Safe and Fast Serialization: feTS uses the response schema to serialize the response body. Rather than JSON.stringify, it uses fast-json-stringifywhich is twice as fast according to the benchmarks found here.
💡

If you want to use JSON Schema formats like uuid, you need to register formats using registerFormats.

import { registerFormats } from 'fets'
 
registerFormats();
 

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']
      }
    }
  } ,
  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:

By default, feTS already extracts and validates path parameters defined in the path. So unless you have some specific format for parameters, you don’t need to define schemas for path parameters..

import { createRouter, Response } from 'fets'
 
const router = createRouter().route({
  method: 'GET',
  path: '/todos/:id',
  // Define the request body schema
  schemas: {
    request: {
      // This is not needed since feTS already extracts and validates path parameters
      params: {
        type: 'object',
        properties: {
          id: { type: 'string' }
        },
        additionalProperties: false,
        required: ['id']
      }
      // Unless it is `uuid` or some specific format
      params: {
        type: 'object',
        properties: {
          id: { type: 'string', format: 'uuid' }
        },
        additionalProperties: false,
        required: ['id']
      }
    }
  } ,
  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().route({
  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"],
      },
    },
  } ,
  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']
      }
    }
  },
  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.

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']
      }
    }
  },
  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().route({
  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']
      }
    }
  },
  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)
  }
})