Using Typescript generics to deduce types from function arguments
December 10th, 2023

Generics in TypeScript. Enemy of few, envy of many. A topic we've all wanted to master but never got around to it.
Every time we see a well typed function using generics in the wild, the mysticism deepens. But so does the curiosity and the desire to master it.

While working on Goodreads Raycast extension, I got to explore Generics in TypeScript and by the end of it moved from a state of envy to enchantment.

If you're new to Generics in TypeScript, TypeScript Generics in 3 Easy Patterns does a great job of capturing the essence with practical examples.

The extension works by scraping the Goodreads page and extracting relevant content from it. Cheerio does most of the heavy lifting here, offering an API similar to easily parse and extract content from HTML. More specifically, the extract method, which takes in a map object whose keys serve as property names of returned object and values are selectors or descriptors to extract values.

Below is a simplified version of the logic.

const extractBookDetails = (html: string) => {
    const bookDetails = extract(html, {
        title: {
            selector: "div.title h1",
        },
        description: {
            selector: "div.description span",
            value: "innerHTML",
        },
    });
    // ^? const bookDetails: { title: string; description: string }
};
 
// slightly more complex example
const extractReviews = (html: string) => {
    const reviews = extract(html, {
        reviews: [
            {
                selector: "div.review-cards",
                value: {
                    reviewerName: {
                        selector: "div.reviewer-name",
                    },
                    reviewText: {
                        selector: "div.review-text",
                        value: "innerHTML",
                    },
                    ...
                },
            },
        ],
    });
    // ^? const reviews: { reviews: { reviewerName: string; reviewText: string; ... }[] }

As you might have rightly noticed, the return type of the extract function could be derived from the map object. This is where generics come in handy.

To infer the return type, we would need to

  • Iterate over the keys of the object.
  • For each key, infer the type of the value, which could be a string, an object or an array of string or object.
  • If the value type is a string, the return type would be a either a string or return type of DOM attributes like innerHTML, innerText, etc.
  • If the value type is an array or object, recursively infer the type of the value.

Alright, time to code.

The extract function takes in a string and an extraction map as arguments and returns an object with extracted values.

type extract = <M extends ExtractMap>(
    html: string,
    extractionMap: M,
): ExtractedMap<M>

Let's break it down.

1
Defining the foundational types

Call it building blocks, if you will.

// Skeletal structure of the extraction map
interface ExtractMap {
    [key: string]: ExtractValue;
}
 
// ExtractValue could be a string, an object or an array of string or objects
type ExtractValue = string | ExtractDescriptor | [ExtractDescriptor | string];
 
// ExtractDescriptor is an object with selector and value properties describing how to extract the value from HTML
interface ExtractDescriptor {
    selector: string;
    value?: string | ExtractMap;
}
2
Iterating over the keys of the object

Keyof Type operator takes in an object type and returns a union of its keys.

type ExtractedMap<M extends ExtractMap> = {
    [K in keyof M]: ExtractedValue<M[K], M>;
};
3
Using conditional types to extract value type

The extraction descriptor in the map could be a string, an object or an array of string or objects.

Conditional Types allow us to conditionally choose a type. They are expressed using the conditional extends keyword.

type ReturnType = SomeType extends OtherType ? TrueType : FalseType;

Let's look at how to handle each descriptor type, starting with the simplest one, string.

String
type ExtractedValue<
    T extends ExtractValue,
    M extends ExtractMap
> = T extends string ? string | undefined : never;
Array

In the case of an array, such as the case when we are trying to extract a list of reviews, the return type would also be an array of extracted value.

type ExtractedValue<T extends ExtractValue, M extends ExtractMap> = T extends [
    ExtractDescriptor | string
]
    ? ExtractedValue<T[0], M>[]
    : never;
Object

When it's an object, it's a nested extraction map and we recursively infer the type as below.

type ExtractedValue<
    T extends ExtractValue,
    M extends ExtractMap
> = T extends ExtractDescriptor
    ? T["value"] extends ExtractMap
        ? ExtractedMap<T["value"]> | undefined
        : T["value"] extends string
        ? ReturnType<typeof prop> | undefined // prop here is the DOM attribute like innerHTML, innerText, etc.
        : string
    : never;
4
Putting it all together
type ExtractedMap<M extends ExtractMap> = {
    [K in keyof M]: ExtractedValue<M[K], M>;
};
 
type ExtractedValue<
    T extends ExtractValue,
    M extends ExtractMap
> = T extends ExtractDescriptor[]
    ? ExtractedValue<T[0], M>[]
    : T extends string
    ? string | undefined
    : T extends ExtractDescriptor
    ? T["value"] extends ExtractMap
        ? ExtractedMap<T["value"]> | undefined
        : T["value"] extends string
        ? ReturnType<typeof prop> | undefined
        : string
    : never;

Hope this helps you on your journey to master Generics in TypeScript.