Generics for Dynamic Type Inference in TypeScript

// #generics#types#typescript // Comment on DEV

This post is a follow-up to my previous post about conditionally returning a different type from a function in TypeScript. In that post, we looked at how to use conditional types to return a different type based on a condition. In this post, I'd like to dive a little deeper into generics with a code example that might be useful for everyday use.

Let's consider the following example. We have a function that serializes an object into different formats depending on the format we pass as a parameter:

type JsonFormat = { type: "json" }; type BinaryFormat = { type: "binary" }; type StreamFormat = { type: "stream" }; type Format = JsonFormat | BinaryFormat | StreamFormat; function serialize( obj: Record<string, unknown>, format: Format, ): any { // ... }

We expect the return type for each function call to be different depending on the format:

const data = { a: 1, b: 2 }; // format is json, return type should be string const s1 = serialize(data, { type: "json"}); // format binary, return type should be Uint8Array const s2 = serialize(data, { type: "binary"}); // format stream, return type should be ReadableStream<Uint8Array> const s3 = serialize(data, { type: "stream"});

To achieve this, we need to connect the parameter type of format to the actual return type of the function. First, we add a generic type parameter TFormat to the function signature:

// ... type Format = JsonFormat | BinaryFormat | StreamFormat; function serialize<TFormat extends Format>( obj: Record<string, unknown>, format: TFormat, ): any { // ... }

The generic type parameter TFormat uses the extends keyword to restricts the type to a particular type or set of types. In this case, it ensures that only sub-types of the union Format can be used as input parameters.

Next, we create the generic type SerializeReturnType to pick the right return type for each format:

// ... type Format = JsonFormat | BinaryFormat | StreamFormat; type SerializeReturnType<TFormat extends Format> = TFormat extends JsonFormat ? string : TFormat extends BinaryFormat ? Uint8Array : TFormat extends StreamFormat ? ReadableStream<Uint8Array> : never; // ...

This generic type returns the right type depending on the generic type parameter TFormat. The type definition uses conditional types to map each possible format to its corresponding return type. The sequence of ? and : characters are actually nested ternary expressions. Therefore, you should read this type definition as an if-else-if ladder:

  • if the TFormat is JsonFormat, the return type is string.
  • else if the TFormat is BinaryFormat, the return type is Uint8Array.
  • else if the TFormat is StreamFormat, the return type is ReadableStream<Uint8Array>.
  • else the TFormat is not one of these three types, the return type is never, meaning that the function cannot return anything.

Finally, we need to connect the generic type of the parameter format to the generic return type of the function:

// ... type Format = JsonFormat | BinaryFormat | StreamFormat; type SerializeReturnType<TFormat extends Format> = TFormat extends JsonFormat ? string : TFormat extends BinaryFormat ? Uint8Array : TFormat extends StreamFormat ? ReadableStream<Uint8Array> : never; function serialize<TFormat extends Format>( obj: Record<string, unknown>, format: TFormat, ): SerializeReturnType<TFormat> { // ... }

The return type of the function is now dependent on the format parameter. When we call the function with different formats, TypeScript is now able to statically infer the return type of the function:

TypeScript
TypeScript Playground