
Product
Announcing Socket Fix 2.0
Socket Fix 2.0 brings targeted CVE remediation, smarter upgrade planning, and broader ecosystem support to help developers get to zero alerts.
@gpa/type-safe-express
Advanced tools
Type-safe-express provides a way to use express in a type-safe manner:
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# 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.
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)
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);
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);
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);
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);
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) });
}
});
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.
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.
Type: string
The path on which the created router will be mounted relative on the parentRouter
.
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.
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.
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.
Type: JsonResponse
| StreamResponse
This type argument determines the expected return value of the request handler.
void
is not supported, but you can use
undefined
or the VoidResponse
alias for a route that does not return any data.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([]);
});
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 }
},
);
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.
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.
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 }
});
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 {
// ...
}
});
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.
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.
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 ComplexResponse<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
.
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.
Declares an error handler on the given router. In the errorHandler
, request.params
is inferred from the path the error handler is
mounted on, and the error
argument can be narrowed if a matcher
is provided.
Type: JsonResponse
| NextErrorHandlerResponse<any>
This type argument determines the expected return type of the error handler. If a JsonResponse
is returned, the HTTP response will be sent
and no further error handlers will be called. If a NextErrorHandlerResponse
is returned, or if the error handler itself throws, the next
error handler will be called.
Type: string
Similarly to the type argument of the same name in declareRoute()
, it can be used to override the path on which the error handler is
mounted. You should not need to use this type argument if the router (and its parent routers) on which the error handler is mounted has been
created with createRouter()
.
Type: TypeAwareRouter
| express.Router
The express router on which the error handler is to be mounted.
Type: TypePredicate
If this parameter is provided, the error handler will only be called if the error matches this TypePredicate. This can be function returning
an explicit or implicit type predicate, a Zod schema or a Yup schema. Additionally, if a matcher is provided, the error
parameter of the
error handler will be typed accordingly.
Note that this parameter is optional and can be omitted, in which case the error handler will always be triggered and the type of the
error
parameter will be unknown
.
Usage:
import { HttpStatusCode } from './status-codes';
const app = express();
declareErrorHandler<string>()(
app,
err => err instanceof TypedRouterValidationError,
// type of `error` is TypedRouterValidationError
error => ({ json: `ValidationError: ${error.message}`, status: HttpStatusCode.BadRequest_400 }),
);
Type: (error: unknown, request: express.Request) => ErrorHandlerReturnValue
This function is where you process the errors thrown by your routers. Just like a standard express error handler, it will be triggered if
any route of this router or its child routers throws an error, and if all the previous error handlers in the call chain have returned a
NextErrorHandlerResponse
or threw an error themselves.
As described in the documentation of the matcher
parameter, the type of the error
parameter will be narrowed to the type validated by
matcher
if it is provided.
The type of request.params
will be inferred from the path of the router. It may contain additional values if the handler that threw the
error had access to other path parameters, but we cannot determine this at compile time so you will have to check for those manually if you
need to access them.
The type of request.body
is always unknown
, you will also have to narrow its type yourself if you want to use it.
Similarly to the request handler in declareRoute()
, the returned value can either be a value with the type of <Response>
, or a
ComplexResponse
that can specify a custom status code, headers and cookies. The default status code for error handlers in 500 (Internal
Server Error).
If this function returns a JsonResponse
, an HTTP response will be sent with the provided value in the response body, and no further
error handler in the call chain will be invoked.
Conversely, if this function returns a NextErrorHandlerResponse
, the next error handler in the call chain will be invoked with the
provided value:
import { declareErrorHandler } from './declare-error-handler';
import { NextErrorHandlerResponse } from './declare-route-types';
const app = express();
declareErrorHandler()(app, (error) => {
// The error parameter of the next error handler will be the MyCustomError returned here
return NextErrorHandlerResponse(new MyCustomError(/*...*/));
});
Type: (router, matcher?, errorHandler) => void
declareErrorHandler()
returns an intermediate function called errorHandlerFactory
, which should be used immediately as described
throughout this documentation. The parameters of errorHandlerFactory
are the ones have been described in this documentation.errorHandlerFactory()
does not return any value, so neither does declareErrorHandler()()
.FAQs
Provide a way to use express routers in a type-safe manner
We found that @gpa/type-safe-express demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 0 open source maintainers collaborating on the project.
Did you know?
Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.
Product
Socket Fix 2.0 brings targeted CVE remediation, smarter upgrade planning, and broader ecosystem support to help developers get to zero alerts.
Security News
Socket CEO Feross Aboukhadijeh joins Risky Business Weekly to unpack recent npm phishing attacks, their limited impact, and the risks if attackers get smarter.
Product
Socket’s new Tier 1 Reachability filters out up to 80% of irrelevant CVEs, so security teams can focus on the vulnerabilities that matter.