Simplifying APIs of mediasoup-based apps in TypeScript with zod

I was recently working on an app that needed to exchange mediasoup-specific data structured with outside world and, obviously, validate inputs.

Doing that manually is tedious so I found an awesome library called zod and used that instead.

I’d like to show how that works, why that might be useful to mediasoup and how it can be integrated smoothly in mediasoup as it is right now without requiring major version change.

Let’s take RtpEncodingParameters as an example, this is how it looks in mediasoup (comments removed):

export type RtpEncodingParameters = {
    ssrc?: number;
    rid?: string;
    codecPayloadType?: number;
    rtx?: {
        ssrc: number;
    };
    dtx?: boolean;
    scalabilityMode?: string;
    scaleResolutionDownBy?: number;
    maxBitrate?: number;
};

Now imagine you receive that from some API as JSON object and want to make sure the data you received actually matches the interface. Naive implementation would look something like this:

function isRtpEncodingParametersValid(
    parameters: any,
): parameters is RtpEncodingParameters {
    return Boolean(
        parameters &&
        (
            parameters.ssrc === undefined ||
            typeof parameters.ssrc === 'number'
        ) &&
        (
            parameters.rid === undefined ||
            typeof parameters.rid === 'string'
        ) &&
        ...
    );
}

And use it like this:

if isRtpEncodingParametersValid(something) {
    const rtpEncodingParameters: RtpEncodingParameters = something;
    // use `rtpEncodingParameters`
}

I didn’t even bother to write the whole thing, I think you get the idea: it is tedious, error-prone and doesn’t produce useful output if validation has failed.

With zod following can be written:

import {z} from 'zod';

export const RtpEncodingParameters = z.object({
    ssrc: z.number().optional(),
    rid: z.string().optional(),
    codecPayloadType: z.number().optional(),
    rtx: z.object({ssrc: z.number()}).optional(),
    dtx: z.boolean().optional(),
    scalabilityMode: z.string().optional(),
    scaleResolutionDownBy: z.number().optional(),
    maxBitrate: z.number().optional(),
});

export type RtpEncodingParameters = z.TypeOf<typeof RtpEncodingParameters>;

And used like this:

const rtpEncodingParameters: RtpEncodingParameters =
    RtpEncodingParameters.parse(something);
// use `rtpEncodingParameters`

The way it works is the following:

  • const RtpEncodingParameters and type RtpEncodingParameters use different namespaces in TypeScript, so they don’t collide with each other
  • const RtpEncodingParameters is a parser for data that has .parse() method returning correct data structure on success or throws an exception if validation fails with nice details telling what exactly went wrong (there are different options for parsing, including such that do not produce exceptions)
  • type RtpEncodingParameters can be used as a type just like before and is done with type inference, so it always matches parser exactly
  • since both parser and type have the same name, you import both of them as one, making it very convenient to work with
  • those custom types can be composed, additional validations are possbile (imagine number should only be positive or in a specific range)
  • transition is straightforward and matches original interface closely

I belive the whole mediasoup library can be refactored to use such approach without breaking changes.

Thoughts?

1 Like