Typescript is a powerful tool that significantly improves the reliability of the javascript code. However, it also adds particular overhead, that developers have to deal with while working with Typescript.
Generic functions are, probably, one of the trickiest but though most powerful concepts of Typescript. In my previous post I briefly touched the topic generics, however, now I would like to dig deeper, and talk about how we can harness the power of generics to deliver scalable and reusable code. Today we will consider four ideas of generic helper functions made with β€οΈand powered by Typescript.
Disclaimer
If you are looking for an ultimate solution with a lot of different methods, you might be interested in checking out great existing libraries such ramda or lodash. The purpose of this post is to discuss some examples, which I find useful in everyday development, and which are suitable for the illustration of Typescript generics. Feel free to add your use-cases in the comments, let's discuss them together πͺ
Table of content
Before we start
For the sake of example, I came up with two simple interfaces and created arrays out of them.
interface Book {
id: number;
author: string;
}
interface Recipe {
id: number;
cookingTime: number;
ingredients: string[];
}
const books: Book[] = [
{ id: 1, author: "A" },
{ id: 2, author: "A" },
{ id: 3, author: "C" }
]
const recipes: Recipe[] = [
{ id: 1, cookingTime: 10, ingredients: ["salad"] },
{ id: 2, cookingTime: 30, ingredients: ["meat"] }
]
1. Map by key
interface Item<T = any> {
[key: string]: T
}
function mapByKey<T extends Item>(array: T[], key: keyof T): Item<T> {
return array.reduce((map, item) => ({...map, [item[key]]: item}), {})
}
Let's look closer to what happens here:
-
interface Item<T = any> { ... }
is a generic interface, with a default value ofany
(yes you can have default values in generics π) -
<T extends Item>(array: T[], key: keyof T)
: TypeT
is inferred from the parameter, but it must satisfy the condition<T extends Item>
(in other wordsT
must be an object). -
key: keyof T
second parameter is constrained to the keys which are only available inT
. If we are usingBook
, then available keys areid | author
. -
(...): Item<T>
is a definition of the return type: key-value pairs, where values are of typeT
Let's try it in action:
mapByKey(books, "wrongKey") // error. Not keyof T -> (not key of Book)
mapByKey(books, "id") // {"1":{"id":1,"author":"A"},"2":{"id":2,"author":"A"},"3":{"id":3,"author":"C"}}
As you can see, we can now benefit from knowing in advance available keys. They are automatically inferred from the type of the first argument. Warning: this helper is handy with unique values like ids; however, if you have non-unique values, you might end up overwriting a value which was previously stored for that key.
2. Group by key
This method is beneficial, if you need to aggregate data based on a particular key, for instance, by author name.
We start by creating a new interface, which will define our expected output.
interface ItemGroup<T> {
[key: string]: T[];
}
function groupByKey<T extends Item>(array: T[], key: keyof T): ItemGroup<T> {
return array.reduce<ItemGroup<T>>((map, item) => {
const itemKey = item[key]
if(map[itemKey]) {
map[itemKey].push(item);
} else {
map[itemKey] = [item]
}
return map
}, {})
}
It's interesting to note, that Array.prototype.reduce
is a generic function on its own, so you can specify the expected return type of the reduce to have better typing support.
In this example, we are using the same trick with keyof T
which under the hood resolves into the union type of available keys.
groupByKey(books, "randomString") // error. Not keyof T -> (not key of Book)
groupByKey(books, "author") // {"A":[{"id":1,"author":"A"},{"id":2,"author":"A"}],"C":[{"id":3,"author":"C"}]}
3. Merge
function merge<T extends Item, K extends Item>(a: T, b: K): T & K {
return {...a, ...b};
}
In the merge example T & K
is an intersection type. That means that the returned type will have keys from both T
and K
.
const result = merge(books[0], recipes[0]) // {"id":1,"author":"A","cookingTime":10,"ingredients":["bread"]}
result.author // "A"
result.randomKey // error
4. Sort
What is the problem with Array.prototype.sort
method? β It mutates the initial array. Therefore I decided to suggest a more flexible implementation of the sorting function, which would return a new array.
type ValueGetter<T = any> = (item: T) => string | number;
type SortingOrder = "ascending" | "descending";
function sortBy<T extends Item>(array: T[], key: ValueGetter<T>, order: SortingOrder = "ascending") {
if(order === "ascending") {
return [...array].sort((a, b) => key(a) > key(b) ? 1 : -1 )
}
return [...array].sort((a, b) => key(a) > key(b) ? -1 : 1 )
}
We will use a ValueGetter
generic function, which will return a primitive type: string or number. It is a very flexible solution because it allows us to deal with nested objects efficiently.
// Sort by author
sortBy(books, (item) => item.author, "descending")
// Sort by number of ingredients
sortBy(recipes, (item) => item.ingredients.length)
// Sort very nested objects
const arrayOfNestedObjects = [{ level1: { level2: { name: 'A' } } }]
sortBy(arrayOfNestedObjects, (item) => item.level1.level2.name)
Summary
In this post, we played around with generic functions in Typescript by writing helper functions for common operations with JS arrays and objects. Typescript provides a variety of tools to produce reusable, composable and type-safe code, and I hope you are enjoying to explore them with me!
If you liked my post, please spread a word and follow me on Twitter πfor more exciting content about web development.
Top comments (9)
In sortBy, wouldn't it be better to write
instead of
to prevent mutation?
Great article!
Thatβs a great catch! thank you π I will fix it
Gleb;
What would T become if we didn't make the = any assignment?
Then βTβ will be a mandatory parameter, you will have to specify. It works very similar to normal js functions.
Mandatory but morphs into what was passed in right?
I am not sure if I got the question. But I will try to give a better example.
Let's say you have a generic interface:
If you set a default value of the generic type,
item3
will not cause an error. It will set the type ofvalue
toany
.You can use any type as a default:
Does it answer your question?
Yes I think I saw no value in this:
I really fail to see what benefit TypeScript gives you. The same could be written in simpler JS very easily. It just feels like TypeScript is a crutch for developers coming from strictly typed languages who are unwilling to modify their habits
When I started with TS, it was a React project, so I was not convinced at all, especially given the good old PropTypes lib for checking react props.
However now typescript is my tool of choice and I think itβs not fair to compare what you can or cannot do with JS instead. You can do anything without TS and with TS you can only go as fas as JS would allow.
Here are the main selling points of TS from my point of view: