Typing Express' res.locals in TypeScript
TypeScript is a good tool to use to prevent bugs in the inconsistent use of variables by ensuring that they’re all of the same type, at least. By default, this restriction does not apply to Express’ res.locals
, which is a map of string
to any
. This can lead to issues, as res.locals
can be set in middleware or earlier routers quite distant from where it is used, which can be hard to notice, and units tests often mock the data, and so it can’t be noticed that way either. This post shows a way to add types to res.locals
.
Creating the types
Create a file express.d.ts
somewhere where it’ll be picked up by your tsconfig.json
. This will use declaration merging to add new properties to Express.
export interface Locals {
errors?: Array<{code?: number, message: string}>
title?: string
}
export as namespace Express
export = Express
declare namespace Express {
export interface Response {
typedLocals: Locals
}
}
Your Locals
interface should contain all the types you want to use with res.locals
. You should access res.locals
and res.typedLocals
: I didn’t succeed trying to override res.locals
directly: I assume that Locals
and Record<string, any>
are merged into Record<string, any>
which is unhelpful.
Assigning the property
Add to your first bit of middleware, or just to the initial startup (given a const app = express()
somewhere):
app.use((_req, res, next) => {
res.typedLocals = res.locals
next()
})
Preventing untyped use
Use a linting tool to disallow direct access to res.locals
. Here’s an example using eslint
:
In package.json
:
{
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.10.0",
"@typescript-eslint/parser": "^4.10.0",
"eslint": "^7.15.0",
"eslint-plugin-regex": "^1.2.1",
"typescript": "^4.1.3"
}
}
In .eslintrc.json
:
{
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"regex"
],
"parserOptions": {
"ecmaVersion": 2020
},
"rules": {
"regex/invalid": [
"error", [
{
"regex": "res\\.locals",
"message": "To ensure types, please use `res.typedLocals`",
"files": {
"ignore": ".*\\.js"
}
}
]
]
}
}
Here we set up a regex to disallow res.locals
, counting on res
as the conventional name for a response object in Express. It won’t stop someone determined to go around it, but should catch most cases. If you have a different conventional name for a response object, update the regex to refer to it.
We ignore .js
files as, in the case of a mixed TypeScript / JavaScript project, the JavaScript files can continue using res.locals
as before. If you plan on migrating to TypeScript relatively soon it might be worth having them all use res.typedLocals
, but if you don’t you can avoid churn by leaving them as they are.
You’ll need to update your middleware:
app.use((_req, res, next) => {
// eslint-disable-next-line regex/invalid
res.typedLocals = res.locals
next()
})
Possible modifications
The keys of the interface can either be allowed to be undefined or not. If they are allowed to be undefined, you should check whether they’re available when you call them, using one of an assert
, an if
check, or a non-null assertion res.typedLocals.property!
. I find the last makes the code the cleanest, but it does remove some of TypeScript’s ability to help you, so be aware of that.
If they are not allowed to be undefined, the initial assignment of res.locals
to res.typedLocals
won’t work unless you type res.locals
:
res.typedLocals = res.locals as Locals
but from then on you can use all the keys without having to check their presence. Again, this means that TypeScript will assume that all keys are always present which is often not correct, so be aware of the meaning of your choice here.
Instead of accessing through res.typedLocals
, you can access through res.locals
appropriately casted. For example:
const locals = res.locals as Locals
locals.title = 'The Title'
If you do this then you’ll want to change your regex to forbid access to res.locals
without a cast:
{
"regex": "res\\.locals(?! as Locals\\b)",
"message": "Ensure res.locals is always case to type Locals",
"files": {
"ignore": ".*\\.js"
}
}