@gpa/type-safe-express

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
Installation
npm install -D typescript @types/node
npm install @gpa/type-safe-express
npm install 'express@>=5.0.0-beta'
npm install -D @types/express
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();
declareRoute<string>()(app, 'get', '/api/:apiVersion/version', {}, (request) => {
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');
declareRoute<string>()(apiRouter, 'get', '/version', {}, (request) => {
return request.params.apiVersion;
});
const userRouter = createRouter(apiRouter, '/users');
declareRoute<UserDto>()(userRouter, 'get', '/:userId', {}, async (request) => {
const user = await UserService.get(request.params.userId);
return UserService.mapUserToDto(user);
});
const userSettingsRouter = createRouter(userRouter, '/:userId/settings');
declareRoute<UserSettingDto>()(
userSettingsRouter,
'get',
'/:userSettingType-:userSettingPath([\w$/-]+)',
{},
async function getUserSettingRequestHandler(request) {
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) => {
await EntityService.updateEntity(request.params.entityId, request.body, { modelVersion: request.query.model_version });
return { ok: true };
});
app.use((err, req, res, next) => {
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',
},
};
});
app.use((err, req, res, next) => {
if (err instanceof TypedRouterStreamingError) {
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) => {
});
declareRoute<{ property: number }>()(router, 'get', '/path', {}, (request) => {
return { property: 0 };
});
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) => {
},
);
router
Type: PathAwareRouter
| express.Router
The express router on which the current route must be declared. Accepts express.Application
s 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) => {
});
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:
const zodSchema = z.object({ property: z.string() });
declareRoute()(router, 'post', '/', { body: zodSchema }, (request) => {
});
const yupSchema = yup.object({ property: yup.string().required() });
declareRoute()(router, 'post', '/', { body: yupSchema }, (request) => {
});
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) => {
});
declareRoute()(router, 'post', '/', { body: v => typeof v === 'number' || typeof v === 'string' }, (request) => {
});
💡 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> = {
status?: HttpStatusCode;
headers?: {
[header: string]: number | string | string[]
};
cookies?: {
[cookieName: string]: string | { value: string; options?: CookieOptions };
};
json: Response;
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.