Type-Safe Request Handlers with Zod and Express
In our starting point code, the
body that are passed into the
makeTypeSafeHandler are used independently of each other and provide separate types of inference.
This is a clue that we need to use two generics for
Let's start by adding
0:00 Let's do this. I'm getting the feeling that I'm going to need a generic for both the query and the body. This is going to be a two-generic job. Why is that? They seem to be totally independent. They don't depend on each other at all. They provide separate types of inference here.
0:22 The question is now, how should I represent this? If I do this, if I do TQuery and then TBody...What I'm going to do is I'm going to hover over z.Schema. I can see that it has an Output in it. This Output actually lets me pass in the thing that I want to output from the schema. I can say, "z.Schema TQuery" and "z.Schema TBody."
0:49 Now, just like that, I should be getting inference based on the things that are passed in. We've got id string and name string, so id string coming from the query and name string coming from the body. That's great already. Fantastic.
1:05 What's next then is we need to do some stuff here. We need to say, "Request." This request on these RequestHandlers...This RequestHandler is the thing that's passed in here. This is where we want the inference to happen. We want the request here to basically be req.query.
1:25 Let's take a look at this again. We have P is core.ParamsDictionary. That's the request.params. ResBody is going to be the thing that's returned from res.send, let's say. ReqBody and then ReqQuery are the things that we want. Request body gets passed in third. Request query gets passed in fourth. Let's do that.
1:53 Let's say we've got our RequestHandler. We're going to say, "any, any." Then we can pass in...Oh God. Which one was it? Request body first, so TBody and then TQuery. Let's just check that again. Yeah, request body and request query.
2:11 We're now getting the inference coming through. req.query is now working for us. Look at that. Very, very nice. That's cracking.
2:22 Now, what's going wrong here then? ParsedQs is not assignable to type TQuery. We're getting an issue where TQuery basically needs to be a bit more constrained. ParsedQs, I think we had a similar issue when we did this before. I can't get this ParsedQs. Where is ParsedQs coming from? I remember this.
2:48 ParamsDictionary ParsedQs. It looks like it's coming from the RequestHandler itself. This is a ReqQuery = core.Query, which is ParsedQs, which is coming from a qs library. Wow. It looks like I can import the type ParsedQs from qs. It should just work. Yeah, there we go. ParsedQs. Here we go. It's a parsed query string here.
3:18 You're still not happy? Assignable to the constraint of type TQuery but could be instantiated with a different subtype of it. It's still not happy. I think the reason is that if I don't return this, then this is going to be annoyed here.
3:37 My instinct says, because there's a mismatch between this RequestHandler and this RequestHandler, I should just be able to pass in "any, any, TBody, TQuery." Look at that. The error went away.
3:53 Why did that happen? I think the reason why that happened is because there was a mismatch. The generic inference wasn't being passed all the way through. It's almost as if I had been passing in ParsedQs from here.
4:10 I was saying I've got this TQuery, which extends ParsedQs but isn't actually necessarily the same as it. Then we've got this ParsedQs down here. Basically, I was saying let me try and mash these two things together.
4:26 I could have done this with an as any, of course. That's another way I could have done it. At least in my mind, it's basically the same here. I'm just trying to solve the problem of forcing TypeScript to do what I want it to do.
4:39 We've got our RequestHandler. Beautiful. That's just working now. When we pass in that from the inference and we say this handler is exactly the same as this handler, then we end up with really, really nice inference. Lovely.
4:55 Now it should default them to any if not passed in config. If they're not passed, what we should be doing is we should be defaulting them to any. When you don't pass them in here, what are they being inferred as, currently? We've got req.query. This is being inferred as just ParsedQs. That's not unreasonable, but let's say that we do want this. Let's say we want to default them to any.
5:20 Inside here, this is being inferred as ParsedQs and unknown. req.body is probably unknown at this point. req.body. Let me check. Yeah, unknown. That's not necessarily unsafe, but let's show how to do this, which is we can default them up here to "= any" and "= any" here too. Now, when they're not passed, req.body is going to be any. Beautiful. req.query, also any.
5:50 This means then if we do decide to type them, then we're going to get strong types. If we don't decide to type them, then we fall back to these weaker types, which is useful when you're halfway through a migration.
6:01 Let's go to the top then. We've got TQuery. I wonder if I can remove this "extends ParsedQs" now. Yeah, these can be anything now. I don't know if that's safe though, because we want it to match up to the shape of the query.
6:14 TBody, we probably also want it to extend a record of string and any, I think, just because that's the sort of thing that you can post to a body when it's passed in terms of JSON. Then TQuery, these are only the sort of things that you're going to be able to pass on a search params here.
6:34 I think that is everything. We go from the top. TQuery, we're inferring it from z.Schema TQuery. TBody, z.Schema TBody. Then we pass in a RequestHandler, which has any, any in the first two slots and then TBody for -- where is it -- ReqBody and then TQuery for ReqQuery.
6:56 Then we return a RequestHandler with the same generic signature -- that's really crucial -- so that one is assignable to the other. Then we don't actually need much in the way of complicated stuff inside here. I'm doing a little bit of casting of the ZodError inside here, because e inside a try catch is always unknown.
7:15 We're calling res.status here. We're saying, "e as ZodError," and then basically returning the message here, which is fairly safe because that's the only thing that we're trying to do inside that try catch.
7:27 Then what we end up with is we can call this. We get all of the beautiful inference inside the type arguments. We can get back all of the exact things that we want to. Everything's all beautiful and type safe. We get a lovely, lovely integration point between two libraries. Well done.