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
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.
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;
}
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>;
};
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.
type ExtractedValue<
T extends ExtractValue,
M extends ExtractMap
> = T extends string ? string | undefined : never;
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;
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;
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.