I currently have a simple express application that provides token-based authentication and utilizes Zod for creating schemas to validate incoming data.
Specifically, I have defined two schemas:
- CreateUserSchema {firstname, lastname, email, pass, passConfirm}
- LoginUserSchema {email, pass}
Zod allows me to infer types based on these schemas, such as:
type SchemaBasedType = z.infer<typeof schema>
Thus, I have two types: CreateUserRequest and LoginUserRequest, derived from my schemas.
To start, I created a validation middleware like this:
export const validateRequest =
<T extends ZodTypeAny>(schema: T): RequestHandler =>
async (req, res, next) => {
try {
const userRequestData: Record<string, unknown> = req.body;
const validationResult = (await schema.spa(userRequestData)) as z.infer<T>;
if (!validationResult.success) {
throw new BadRequest(fromZodError(validationResult.error).toString());
}
req.payload = validationResult.data;
next();
} catch (error: unknown) {
next(error);
}
};
As mentioned above, this middleware accepts a schema argument that is typed according to the Zod documentation. I found it beneficial to extend the request object with the "payload" property to store valid data.
However, issues arose when TypeScript didn't recognize the payload type. This is where declaration merging comes into play. Initially, I attempted something like this:
declare global {
namespace Express {
export interface Request {
payload?: any;
}
}
}
Yet, this approach did not seem ideal, as we know the specific signature of our payload. Thus, I experimented with a union type based on Zod types:
payload?: CreateUserRequest | LoginUserRequest;
This method revealed discrepancies when some fields in a narrower type were missing in another type.
Subsequently, I explored using a generic approach:
declare global {
namespace Express {
export interface Request<T> {
payload?: T;
}
}
}
While this seemed promising, the Request interface already had 5 generic arguments. This raised questions about how the merging would occur—would my generic argument be first or last?
Feeling uncertain, I sought advice online and discovered an alternate strategy:
declare global {
namespace Express {
export interface Request<
Payload = any,
P = ParamsDictionary,
ResBody = any,
ReqBody = any,
ReqQuery = ParsedQs,
LocalsObj extends Record<string, any> = Record<string, any>
> {
payload?: Payload;
}
}
}
Although this solution provided helpful hints, assigning "any" as the type felt inadequate given the types inferred by Zod. Without specifying Payload = any, I wouldn't receive type hints.
Struggling with these complexities, as I am not well-versed in TypeScript and backend architecture, I'm stuck at this point.
Ultimately, my goal is to achieve something like this:
authRouter.post("/register", validateRequest(createUserSchema), AuthController.register);
where the compiler recognizes the payload signature as equal to CreateUserRequest.
authRouter.post("/login", validateRequest(loginUserSchema), AuthController.login)
;
where the compiler identifies the payload signature as equal to LoginUserRequest.
How can I properly specify the expected types and effectively manage them?