In express
, the value of req.params
is an object, which can only have string properties.
By default, req.params
is typed to be Record<string, string>
, which isn't really useful. I want to provide typing information to the compiler so that it knows which properties are expected and not expected inside req.params
.
I can do it in many ways. What I ended up doing is creating an abstraction that allows me to define the shape of req.params
using a class definition. This is useful because I can give this class to class-validator
and have runtime validations as well for free.
This abstraction accepts the class and returns a typed request handler. Something along the lines of:
declare function createRequestHandler<Params extends object>(
paramsConstructor: new (...args: never) => Params,
handler: express.RequestHandler<Params>,
): express.RequestHandler<Params>
class GetUserByIdParams {
userId!: string
}
app.get('/users/:userId', createRequestHandler(GetUserByIdParams, (req) => {
req.params.userId; // ✅ well-typed
}))
The problem is that I can define the params class to have any properties whatsoever, not only string
or derived from string
, but also number
, boolean
, a composite object, – which is, of course, not something what the runtime value of req.params
can have.
class InvalidParams {
userId!: number
}
app.get('/users/:userId', createRequestHandler(InvalidParams, (req) => {
req.params.userId.toFixed(2); // ❌ method 'toFixed' doesn't exist, but no errors here
}))
Yes, I get validation errors from class-validator
when I actually run this code, but I want to have this constraint at design time as well.
How do I restrict Params
to only have string properties?
In express
, the value of req.params
is an object, which can only have string properties.
By default, req.params
is typed to be Record<string, string>
, which isn't really useful. I want to provide typing information to the compiler so that it knows which properties are expected and not expected inside req.params
.
I can do it in many ways. What I ended up doing is creating an abstraction that allows me to define the shape of req.params
using a class definition. This is useful because I can give this class to class-validator
and have runtime validations as well for free.
This abstraction accepts the class and returns a typed request handler. Something along the lines of:
declare function createRequestHandler<Params extends object>(
paramsConstructor: new (...args: never) => Params,
handler: express.RequestHandler<Params>,
): express.RequestHandler<Params>
class GetUserByIdParams {
userId!: string
}
app.get('/users/:userId', createRequestHandler(GetUserByIdParams, (req) => {
req.params.userId; // ✅ well-typed
}))
The problem is that I can define the params class to have any properties whatsoever, not only string
or derived from string
, but also number
, boolean
, a composite object, – which is, of course, not something what the runtime value of req.params
can have.
class InvalidParams {
userId!: number
}
app.get('/users/:userId', createRequestHandler(InvalidParams, (req) => {
req.params.userId.toFixed(2); // ❌ method 'toFixed' doesn't exist, but no errors here
}))
Yes, I get validation errors from class-validator
when I actually run this code, but I want to have this constraint at design time as well.
How do I restrict Params
to only have string properties?
1 Answer
Reset to default 0There are two ways: 1) easy and annoying, and 2) complex and robust.
Easy and annoying: index signatures
The easy way to restrict class properties to be string
s is to add an index signature:
class Params {
[key: string]: string
userId!: number // ✅ Error: Property 'userId' of type 'number' is not assignable to 'string' index type 'string'
}
But then again, nobody stops the developer from just not including it:
class Params {
userId!: number // ❌ no errors
}
Index signatures can be enforced by createRequestHandler
with a small change in the type argument constraint: from extends object
to extends Record<string, string>
:
declare function createRequestHandler<Params extends Record<string, string>>(…): …
class Params {
userId!: number
}
createRequestHandler(Params, () => …)
// ^^^^^^
// ✅ Error: Index signature for type 'string' is missing in type 'Params'
This makes index signatures unavoidable. But index signatures have one other unfortunate functionality: they allow accessing unknown properties:
enum UserRole { Admin = 'Admin', User = 'User' }
class Params {
[key: string]: string
userId!: string
userRole!: UserRole
}
new Params().userId // ✅ string
new Params().userRole // ✅ UserRole
new Params().fooBar // ❌ string, no error
new Params().literallyDoesNotExist // ❌ string, no error
This makes the approach just barely more useful compared to the default Record<string, string>
.
Complex and robust
A more complex but much more powerful approach would be to gather the properties of the model and assess that all of them are assignable to string
(meaning, the property value is either a string
, or something that derives from string
, like a string literal type, or an enum that resolves to string values). This would make an input class invalid if any one of its properties is something more generic than string
(e.g., string | number
or unknown
).
The keyof
operator can help to get a union of all values of all properties of an object:
type ValuesOf<Obj extends object> = Obj[keyof Obj]
Try it.
A simple type utility that checks if one type is derived from another type would look like this:
type SubType<Type, SuperType> = [Type] extends [SuperType] ? Type : never
Try it.
Combining the above, this type utility returns never
if at least one property of the object is more general than string
:
type ObjWithStringProps<Obj extends object> = [Obj[keyof Obj]] extends [string] ? Obj : never;
Try it.
To apply this to createRequestHandler
, it is instructed to make sure that the given class constructs an instance which only has string
or string
-derived properties:
declare function createRequestHandler<Params extends object>(
paramsConstructor: new (...args: never) => ObjWithStringProps<Params>,
handler: …
): …
enum UserRole { Admin = 'Admin', User = 'User' }
class Params {
userId!: string
userRole!: UserRole
}
createRequestHandler(Params, (req) => {
req.params.userId // string
req.params.userRole // UserRole
req.params.doesNotExist; // ✅ Error: Property 'doesNotExist' does not exist on type 'Params'
})
Try it.