Generics for Dynamic Type Inference in TypeScript
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
isJsonFormat
, the return type isstring
. - else if the
TFormat
isBinaryFormat
, the return type isUint8Array
. - else if the
TFormat
isStreamFormat
, the return type isReadableStream<Uint8Array>
. - else the
TFormat
is not one of these three types, the return type isnever
, 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: