npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@gpa/type-safe-express

v1.0.1

Published

Provide a way to use express routers in a type-safe manner

Downloads

115

Readme

@gpa/type-safe-express

Gitlab Pipeline Status GitLab Issues GitLab License Node Current NPM Unpacked Size

Type-safe-express provides a way to use express in a type-safe manner:

  • The path parameters available in each request handler are automatically extracted from the path on which the request handler is mounted
  • Runtime validators for request.body and request.query can be provided, in which case these values will be typed according to the validated type in the request handler
  • The response type of each route must be explicitly specified, which prevents accidentally returning an incorrect value in request handlers

Table of Contents

  1. Installation
  2. Requirements
  3. Usage
    1. Basic usage
    2. Router composition
    3. Validation with Zod
    4. Response status and headers
    5. Streaming
  4. API
    1. createRouter(parentRouter, pathSpec, routerOptions)
    2. declareRouter<Response, ParentPath>()(router, method, pathSpec, validators, requestHandler)

Installation

# Install Typescript
npm install -D typescript @types/node
# Install the library
npm install @gpa/type-safe-express
# Install peer dependencies: latest express 5.x and @types/express
npm install 'express@>=5.0.0-beta'
npm install -D @types/express
# Optional dependencies: install a validation library
npm install zod
npm install yup

⚠️ If you do not explicitly add a dependency on express 5, then express 4 will be installed in your node_modules (unless a stable version of express 5 has been released at the time you are reading this). Read the Requirements section below for information on compatibility with express 4.

Requirements

  • Node.js >= 18

  • typescript >= 5.4

    This library uses the NoInfer intrinsic type introduced in TypeScript 5.4

  • express >= 5.0

    express 4 is partially supported, but path syntax breaking changes have been introduced in express@5 and the now-deprecated syntaxes are not supported by this package.

  • zod >= 3.0 (optional)

  • yup >= 1.4 (optional)

Usage

Basic usage

import { declareRoute } from '@gpa/type-safe-express';
import express from 'express';

const app = express();

// The first type argument of declareRoute determines the expected return type of the route
// By default, the response is returned with a "200 OK" status code
declareRoute<string>()(app, 'get', '/api/:apiVersion/version', {}, (request) => {
  // request.params is inferred as { apiVersion: string }
  return request.params.apiVersion;
});

app.listen(3000);

Router composition

import { declareRoute, createRouter } from '@gpa/type-safe-express';
import express from 'express';
import { UserDto, UserSettingDto } from './entities/user';
import { UserService } from './services/user.service.js';

const app = express();

const apiRouter = createRouter(app, '/api/:apiVersion');
// Do not add a type-annotation to the request parameter to let the library infers its precise type
declareRoute<string>()(apiRouter, 'get', '/version', {}, (request) => {
  // request.params is inferred as { apiVersion: string }
  return request.params.apiVersion;
});

const userRouter = createRouter(apiRouter, '/users');
// If the router parameter has been created with createRouter, all the path parameters declared on parent routers will be available 
// in request.params
declareRoute<UserDto>()(userRouter, 'get', '/:userId', {}, async (request) => {
  // request.params is inferred as { apiVersion: string, userId: string }
  const user = await UserService.get(request.params.userId);
  return UserService.mapUserToDto(user);
});

const userSettingsRouter = createRouter(userRouter, '/:userId/settings');
declareRoute<UserSettingDto>()(
  userSettingsRouter, 
  'get',
  // Even "complex" path parameters are properly parsed by the library
  '/:userSettingType-:userSettingPath([\w$/-]+)', 
  {}, 
  // You can use a named and/or async function as the request handler
  async function getUserSettingRequestHandler(request) {
    // request.params is inferred as { apiVersion: string, userId: string, userSettingType: string, userSettingPath: string }
    const { userId, userSettingType, userSettingPath } = request.params;
    const userSetting = await UserService.getUserSetting(userId, { type: userSettingType, path: userSettingPath });
    return UserService.mapUserSettingToDto(userSetting);
  },
);

app.listen(3000);

Request body and query validation with Zod

import { declareRoute, TypedRouterValidationError, HttpStatusCode } from '@gpa/type-safe-express';
import express from 'express';
import { z } from 'zod';
import { EntityService } from './services/entity.service.js';

const bodySchema = z.object({ id: z.number(), name: z.string() });
const querySchema = z.object({ model_version: z.number().optional() });

const app = express();
app.use(express.json());

declareRoute<{ ok: boolean }>()(app, 'put', '/entity/:entityId', { body: bodySchema, query: querySchema }, async (request) => {
  // => request.body has been validated with the bodySchema and is now inferred as { id: number, name: string }
  // => request.query has been validated with the querySchema and is now inferred as { model_version?: number }
  await EntityService.updateEntity(request.params.entityId, request.body, { modelVersion: request.query.model_version });

  return { ok: true };
});

// Basic express error handler to handle validation errors
app.use((err, req, res, next) => {
  // The type of the error can be further narrowed with `err instanceof TypedRouterZodValidationError` 
  // or `err instanceof TypedRouterYupValidationError`
  if (err instanceof TypedRouterValidationError) {
    res.status(HttpStatusCode.BadRequest_400).send({ ok: false, message: err.message });
  } else {
    res.status(HttpStatusCode.InternalServerError_500).send({ ok: false, message: String(err) });
  }
});

app.listen(3000);

Response with custom status and headers

import { declareRoute, HttpStatusCode, VoidResponse } from '@gpa/type-safe-express';
import express from 'express';
import { SERVER_VERSION } from './constants.js';

const app = express();

declareRoute<VoidResponse>()(app, 'get', '/api', {}, (request) => {
  return {
    json: undefined,
    status: HttpStatusCode.NoContent_204,
    headers: {
      'Date': new Date().toUTCString(),
      'X-Server-Version': SERVER_VERSION,
    },
    cookies: {
      mycookie: {
        value: 'myvalue',
        options: { expires: new Date(Date.now() + 60000), secure: true },
      },
    },
  };
});

app.listen(3000);

Response streaming

import { declareRoute, TypedRouterStreamingError, StreamResponse } from '@gpa/type-safe-express';
import { Readable } from 'node:stream';
import express from 'express';

const app = express();
app.use(express.json());

declareRoute<StreamResponse>()(app, 'get', '/entity/:entityId/resource/:resourcePath+', {}, async (request) => {
  const resourceReader: Readable = await service.getEntityResourceReader(request.params.entityId, request.params.resourcePath);
  return {
    stream: resourceReader,
    headers: {
      'keep-alive': 'timeout=2',
    },
  };
});

// Basic express error handler to handle streaming errors
app.use((err, req, res, next) => {
  if (err instanceof TypedRouterStreamingError) {
    // Note that when TypedRouterStreamingError are thrown:
    // - the response head has already been sent, so you cannot send a status code or any new headers
    // - `.destroy()` has been called on both the `express.Response` stream and the readable stream returned by the request handler
    console.error(`Error while streaming response: ${err.cause}`);
  } else {
    res.status(500).send({ ok: false, message: String(err) });
  }
});

API

createRouter(parentRouter, pathSpec, routerOptions?)

Creates a new express.Router mounted on parentRouter on the given path. Routers returned by this method will keep track of the path on which they have been mounted, which allows declareRoute() to properly analyze the path parameters declared on parent routers.

parentRouter

Type: PathAwareRouter | express.Router

The router on which the current router will be mounted.

See the documentation of the value returned by this method below for more information on the PathAwareRouter type.

pathSpec

Type: string

The path on which the created router will be mounted relative on the parentRouter.

routerOptions

Type: express.RouterOptions | undefined

The options used when instantiating the express.Router. See the express documentation for more information.

⚠️ By default, the express.Router will be created with { mergeParams: true }. Setting this option to false will prevent routers from accessing the path parameters declared on their parent routers at runtime.

Returned value

Type: PathAwareRouter

The created router.

💡 PathAwareRouter is a branded type of express.Router which keeps track of the path specification of its parent router or routers. At runtime, there are no differences between a PathAwareRouter and an express.Router, this is only visible to the Typescript compiler.

declareRoute<Response = undefined, ParentPath = ''>()(router, method, pathSpec, validators, requestHandler)

Declares a new route on the given router. The requestHandler function is mostly type-safe: the type of request.params, request.body and request.query have been narrowed according to the path specification and the types validated by the validators parameter, and the type of the value returned in the HTTP response is controlled by the <Response> type argument.

<Response>

Type: JsonResponse | StreamResponse

This type argument determines the expected return value of the request handler.

  • For JSON endpoints, any JSON-compatible value can be used (so no functions or classes). void is not supported, but you can use undefined or the VoidResponse alias for a route that does not return any data.
  • Using StreamResponse or any type implementing NodeJS.ReadableStream will make the request handler streams its response with a Transfer-Encoding: chunk header.

Usage:

import { declareRoute, VoidResponse, StreamResponse } from '@gpa/type-safe-express';
import { Readable } from 'node:stream';

const router = express.Router();
declareRoute<VoidResponse>()(router, 'get', '/', {}, (request) => {
  // return 'string'; => Error: Type string is not assignable to type RequestHandlerReturnValue<undefined>
  // return { json: 'string' }; => Error: Type string is not assignable to type undefined
  // return; => Valid response
  // return undefined; => Valid response
  // return { json: undefined }; => Valid response
  // no return statement => Valid response
});
declareRoute<{ property: number }>()(router, 'get', '/path', {}, (request) => {
  // no return statement => Error: Type void is not assignable to type RequestHandlerReturnValue<NoInfer<{ property: number; }>>
  // return { property: 'a string' }; => Error: Type { property: string; } is not assignable to type { property: number; }
  return { property: 0 }; // => Valid response
});
declareRoute<StreamResponse>()(router, 'get', '/stream', {}, (request) => {
  return Readable.from([]);
});

<ParentPath>

Type: string

This type argument allows the request.params in the request handler to also include path parameters declared in parent routers.

If the router passed as the first argument has been created using createRouter(), you do not need to explicitly give a value to this type argument as it will be automatically inferred from the TypeAwareRouter.

💡 The express.Router passed as the first function parameter must be declared using the { mergeParams: true } options to also expose parent router parameters under request.params. Routers created with createRouter() use this option by default.

Usage:

const app = express();
const router = express.Router({ mergeParams: true });
const parentPath = '/parentPath/:parentParam';
app.use(parentPath, router);
declareRoute<void, typeof parentPath>()(
  router,
  'get',
  '/:childParam',
  {},
  (request) => {
    // => type of `request.params` is { parentParam: string, childParam: string }
  },
);

router

Type: PathAwareRouter | express.Router

The express router on which the current route must be declared. Accepts express.Applications as well since they extend routers.

method

Type: ExpressHttpMethod

The allowed values for this parameter are automatically extracted from the express library into the ExpressHttpMethod type.

Non-exhaustive list of supported values: get, post, put, patch, delete, options, all, etc.

pathSpec

Type: string

A standard express path specification which will ultimately be parsed by the path-to-regexp module.

Path parameters will be automatically extracted from this string to provide a precise type for request.params in the request handler, this includes named and unnamed parameters. Parameter quantifiers (?*+) are taken into account so that optional parameters are typed as being potentially undefined in request.params.

⚠️ Express@5 introduces multiple breaking changes on the allowed path syntax, this library implements these changes and is therefore not entirely compatible with Express@4 paths.

Usage:

declareRoute()(router, 'get', '/path/prefix-:param1.:param2(\\w{2,3})+/path/(\\d+)+/path/:param3*', {}, (request) => {
  // => type of `request.params` is inferred as { param1: string, param2: string, param3?: string | undefined, 0: string }
});

validators

Type: { body?: TypePredicate, query?: TypePredicate }

TypePredicate can be a function returning an explicit or implicit (with Typescript >= 5.5) type-predicate, a zod.Schema or a yup.Schema.

Usage:

// With a zod.Schema
const zodSchema = z.object({ property: z.string() });
declareRoute()(router, 'post', '/', { body: zodSchema }, (request) => {
  // => type of `request.body` is inferred as { property: string }
});

// With a yup.Schema
const yupSchema = yup.object({ property: yup.string().required() });
declareRoute()(router, 'post', '/', { body: yupSchema }, (request) => {
  // => type of `request.body` is inferred as { property: string }
});

// With an explicit type predicate
const typePredicate = (value: unknown): value is { property: string } =>
  typeof value === 'object'
  && value !== null
  && 'property' in value
  && typeof value.property === 'string';
declareRoute()(router, 'post', '/', { body: typePredicate }, (request) => {
  // => type of `request.body` is inferred as { property: string }
});

// With an implicit type predicate (Typescript >= 5.5)
declareRoute()(router, 'post', '/', { body: v => typeof v === 'number' || typeof v === 'string' }, (request) => {
  // => type of `request.body` is inferred as string | number
});

💡 A TypedRouterValidationError (or TypedRouterZodValidationError if using Zod, or TypedRouterYupValidationError if using Yup) will be thrown if a validation fails, which you probably want to handle in an appropriate error handler:

declareRoute()(/* ... */);

app.use((err: unknown, req, res, next) => {
  if (err instanceof TypedRouterValidationError) {
    res.status(400).send('Bad Request');
  } else {
    // ...
  }
});
validators.body

Type: TypePredicate | undefined

This validator is used to infer the type of request.body in the request handler and to actually validate the body received in the HTTP request at runtime.

If no value is provided for validators.body, request.body will have the type unknown in the request handler.

💡 When using a Zod or Yup validator, the output of zodSchema.parse(rawRequestBody) or yupSchema.cast(rawRequestBody) will overwrite the request.body value, so any property not declared in the schema will be removed, and any value parsed/coerced/transformed by the schema will also be transformed in request.body.

Conversely, when using a type predicate, the value of the request.body will not be changed whatsoever, so any property not declared in the RequestBody type will still be present in request.body, unless the type predicate explicitly checks for extraneous properties.

💡 You need a body-parser when initializing your express Application to
receive parsed JSON values in the body of the incoming HTTP requests.

validators.query

Type: TypePredicate | undefined

This validator is used to infer the type of request.query in the request handler and to actually validate the parsed query-string received in the HTTP request at runtime.

If no value is provided for validators.query, request.query will have the type unknown in the request handler.

💡 Contrary to validators.body, request.query cannot be overwritten so the runtime value will always include any property that is not declared in the TypePredicate.

💡 By default, express 5 uses the simple query parser setting, which defers the query-string parsing to the node:querystring module. If you are using express 4, the default query parser setting is extended as described below.

By using the extended query parser (app.set('query parser', 'extended')), the parsing is deferred to the qs package, which allows for more complex structures to be parsed from the query-string.

Please refer to the documentation of these modules for more information on the supported query-string syntax.

requestHandler

Type: (request: express.Request) => RequestHandlerReturnValue

This function is where you can process an incoming HTTP request on the declared route.

The request parameter is a standard express.Request object that has been typed according to the other arguments: request.body has the type validated by the validators.body parameter, and request.query the type validated by the validators.query parameter.

The returned value should either be a value with the type of <Response> (the first type argument of declareRoute), or a ComplexResponse object which has the following structure:

type TypedRouteComplexResponse<Response> = {
  // HttpStatusCode is an exported enum containing all the standard HTTP status codes. This property also accepts numbers as long as they
  // correspond to an existing status code.
  status?: HttpStatusCode;
  headers?: {
    [header: string]: number | string | string[]
  };
  cookies?: {
    // CookieOptions is documented in the express documentation: https://expressjs.com/en/api.html#res.cookie
    [cookieName: string]: string | { value: string; options?: CookieOptions };
  };
  // For JSON responses:
  json: Response;
  // For streaming responses (with Response extends ResponseType):
  stream: Response;
};

💡 The returned value can be a Promise or a synchronous value, so async handlers are perfectly fine.

💡 If you need to use the express.Response object in your handler, you can access it under request.res.

Returned value

Type: (router, method, pathSpec, validators, requestHandler) => void

  • declareRoute() returns an intermediate function called routerFactory, which should be used immediately as described throughout this documentation. The parameters of routerFactory are the ones have been described in this documentation.
  • routerFactory() does not return any value, so neither does declareRoute()().

💡 The reason behind the existence of this intermediate function routerFactory() is that the type arguments of declareRoute() are to
be manually provided, while the type arguments of routerFactory() are inferred from its function arguments. In Typescript, a function cannot easily have optional non-inferred type-arguments and inferred type arguments, so this is the best way to solve this issue.