How to Create Typed Generic Validators with Zod

In the last few TypeScript projects I’ve worked on, I’ve been leaning on Zod as a validator on the edges, taking in data of unknown shape and asserting at runtime that it matches the types we need to use.

This works great when we know the entire shape of the object. But what about when we might not know the Zod type for part of that shape? How do we do that and maintain type safety throughout?

The Validator-First Pattern

For purely internal types, instantiated from scratch with code, I still write TypeScript types. But for types that we use as an API surface, I write the Zod type first, then infer and export a derivative TypeScript type.

For example:

export const RegisterMessage = z.object({
  type: z.literal("REGISTER"),
  identity: z.string()
});

export type RegisterMessage = z.infer<typeof RegisterMessage>

Now, whenever using “RegisterMessage” in code, it contextually works as a validator or as a standalone type (e.g. as a function argument or a return type), depending on our needs.

Building a Generic Validator

Because Zod validators are JavaScript objects, we can pass them into functions that will create validators that use them to handle parts of our larger Zod types.

Here’s an envelope type that can handle wrapping any kind of message (like our RegisterMessage above) with sender and receiver information.

const Envelope = <TMessageType extends ZodType>(
  messageType: TMessageType
) =>
  z.object({
    from: z.string(),
    to: z.string(),
    message: messageType
  });

Now, when we want to create an enveloped RegisterMessage validator, we do this:

export const RegisterMessageEnvelope = Envelope(RegisterMessage);

This is great, and we can even use z.infer on RegisterMessageEnvelope to get a specific type for enveloped RegisterMessage.

But what if I want to write a generic function that handles any Envelope-based validator and type? How do I get to something like “Envelope<T>”, where the function doesn’t necessarily know what T is?

Typing the Validator

There are a couple of type paths we need to think about to get this off the ground. Two types relate to the message: the type of the validator used to validate it, and the output type. We don’t want to commit to a concrete type for these, so let’s call these “TMessageType” (named similarly to ZodType) for the validator and “TMessage” for the output.

There are also a couple of types that relate to the envelope. We’ll use “EnvelopeType” for the validator. And, finally, our goal is to get to “Envelope<TMessage>”, so “Envelope” will be our output type, using TMessage as a parameter.

If you look back up to the previous section where we created the Envelope validator, we’re already using TMessageType here to require a Zod validator for the message. So, first, let’s work out our type for the Envelope validator, using a few utility types along the way.

We’ll use what’s called an instantiation expression to help us out here.

type EnvelopeType<TMessageType extends ZodType> = ReturnType<
  typeof Envelope<TMessageType>
>

Reading from the inside out:

  1. “typeof Envelope” refers to the Envelope function we created above that generates a Zod validator. We supply a generic type for its parameter, which is a validator for the inner message.
  2. We then use the ReturnType utility type to pull out what we’re interested in: the generated Zod validator.

The Output Type

Now that we have this, we’re very close to our “Envelope<TMessage>” type. It looks like this:

type Envelope<TMessage> = z.infer<EnvelopeType<ZodType<TMessage>>>

Again, from the inside out:

  1. “ZodType<TMessage>” uses “ZodType”, whose first type parameter is the output of the validator. This gives us the type of a Zod validator that will output our message.
  2. “EnvelopeType” we defined above. This takes the type of the message validator and wraps it to get an envelope validator that handles that message.
  3. Finally, we have an expression we can use “z.infer” on — and we have our final generic type.

“Undefined” Strikes Again

We can use this as a parameter in a generic function quite easily:

const open = <TMessage>(envelope: Envelope<TMessage>) => envelope.message

However, be aware that, because this validator is thoroughly generic and will accept any validator for the “message” property, it will also accept something like this, which means “message” can be entirely omitted:

const MaybeMessage = z.object({ text: string }).optional();
const PossiblyEmptyEnvelope = Envelope(MaybeMessage);

This means that our “open” function above’s return type isn’t equivalent to TMessage, but rather “TMessage | undefined”.

Somehow, we’ll need to handle the undefined. For example, we can wrap that right up in our function:

const open = <TMessage>(envelope: Envelope<TMessage>): TMessage => {
  if (typeof envelope.message === "undefined")
    throw new Error("message is undefined");
  return envelope.message;
};

This isn’t a problem if you use a concrete Zod validator as a type parameter to “Envelope” instead of a generic (e.g. “Envelope<RegisterMessage>”). Because it can’t be optional, it can’t be undefined.

All Together Now

To recap, this is what the validator-first pattern looks like for our Envelope type, in its entirety:

export const Envelope = <TMessageType extends ZodType>(
  messageType: TMessageType
) =>
  z.object({
    from: z.string(),
    to: z.string(),
    message: messageType
  });

type EnvelopeType<TMessageType extends ZodType> = ReturnType<
  typeof Envelope<TMessageType>
>

type Envelope<TMessage> = z.infer<EnvelopeType<ZodType<TMessage>>>

It’s less verbose than z.infer, to be sure. But it gets the job done, and we won’t be doing this nearly as often as we create, say, different kinds of messages to go inside the Envelope.

And with that, we’ve achieved our goal: a generic validator and named inferred types that we can use anywhere else.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *