Generics
Generics allows us to define placeholder types which are then replaced when the code is executed with the actual types passed in.
Let’s consider a simple generics before diving into more complex generics:
<Input, Output>
: These are generic types.Input
represents the type of elements in the input array, andOutput
represents the type of elements in the output array.arr: Input[]
: This is the input array, containing elements of typeInput
.func: (arg: Input) => Output
: This is a function that takes an element of typeInput
and returns a value of typeOutput
.Output[]
: This is the return type, an array containing elements of typeOutput
.
1function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): Output[] {2return arr.map(func);3}45const parsed = map(["1", "2", "3"], (n) => parseInt(n));67console.log(parsed) // [ 1, 2, 3 ]
Writing utility functions
Let’s try writing a few common utility functions: map()
filter()
reduce()
But, you have a constraint which it must only be applicable to an Index Signatures:
1interface Dict<T> {2[k: string]: T;3}
Say you’re given this input:
1const fruits = {2apple: { color: "red", mass: 100 },3grape: { color: "red", mass: 5 },4banana: { color: "yellow", mass: 183 },5lemon: { color: "yellow", mass: 80 },6pear: { color: "green", mass: 178 },7orange: { color: "orange", mass: 262 },8raspberry: { color: "red", mass: 4 },9cherry: { color: "red", mass: 5 },10};
What if you want to attach a kg
and name
prop to it?
Let’s start with our map()
function:
1function mapDict<T, S>(inputDict: Dict<T>, mapFunction: (original: T, key: string) => S): Dict<S> {2const outDict: Dict<S> = {};3for (let k of Object.keys(inputDict)) {4const val = inputDict[k];5outDict[k] = mapFunction(val, k);6}7return outDict;8}
Let’s break it down:(hover:one)
<T, S>
: These are generic types.T
represents the type of values in theinputDict
, andS
represents the type of values in the resulting dictionary.inputDict: Dict<T>
: This is the input dictionary, where keys are strings and values are of typeT
.mapFunction: (original: T, key: string) => S
: This is a function that takes a value of typeT
and its corresponding key, and returns a new value of typeS
.Dict<S>
: This is the return type, a dictionary with the same keys as inputDict, but with values of typeS
.
1function mapDict<T, S>(2inputDict: Dict<T>,3mapFunction: (original: T, key: string) => S4): Dict<S>
Once called:
1const fruitsWithKgMass = mapDict(fruits, (fruit, name) => ({ ...fruit, kg: 0.001 * fruit.mass, name }));23console.log(fruitsWithKgMass);
1{2apple: { color: 'red', mass: 100, kg: 0.1, name: 'apple' },3grape: { color: 'red', mass: 5, kg: 0.005, name: 'grape' },4banana: { color: 'yellow', mass: 183, kg: 0.183, name: 'banana' },5lemon: { color: 'yellow', mass: 80, kg: 0.08, name: 'lemon' },6pear: { color: 'green', mass: 178, kg: 0.178, name: 'pear' },7orange: { color: 'orange', mass: 262, kg: 0.262, name: 'orange' },8raspberry: { color: 'red', mass: 4, kg: 0.004, name: 'raspberry' },9cherry: { color: 'red', mass: 5, kg: 0.005, name: 'cherry' }10}
Comprehend what each type is. Once you’ve understood the generics given to you thus far, the rest of the functions are identical:
1function filterDict<T>(inputDict: Dict<T>, filterFunction: (value: T, key: string) => boolean): Dict<T> {2const outDict: Dict<T> = {};3for (let key of Object.keys(inputDict)) {4const val = inputDict[key];5if (filterFunction(val, key))6outDict[key] = val;7}8return outDict;9}1011function reduceDict<T, S>(12inputDict: Dict<T>,13reducerFunction: (14currentVal: S,15dictItem: T,16key: string17) => S,18initialValue: S19): S {20let value = initialValue21for (let k of Object.keys(inputDict)) {22const thisVal = inputDict[k]23value = reducerFunction(value, thisVal, k)24}25return value26}2728const redFruits = filterDict(fruits, (fruit) => fruit.color === 'red');29const oneOfEachFruitMass = reduceDict(fruits, (currentMass, fruit) => currentMass + fruit.mass, 0);
You can also use Alias:
1// Define the type for FruitType2type FruitType = {3color: string;4mass: number;5};67// Using mapDict to add 'kg' mass and 'name' to each fruit8const fruitsWithKgMass = mapDict<FruitType, FruitType & { kg: number; name: string }>(9fruits,10(fruit, name) => ({11...fruit,12kg: 0.001 * fruit.mass, // Convert mass to kilograms13name, // Add the name of the fruit14})15);
More Generics
Constraints
Let’s write a function that returns the longer of two values. To do this, we need a length property that’s a number. We constrain the type parameter to that type by writing an extends
clause:
1function longest<Type extends { length: number }>(a: Type, b: Type): Type {2if (a.length >= b.length) {3return a;4} else {5return b;6}7}89function longTwo<T extends { length: number }>(a: T, b: T): T {10if (a.length == 1) {11return a;12} else {13return b;14}15}1617const longerArray = longest([1, 2], [1, 2, 3]);const longerArray: number[]1819const notOK = longest(10, 100); // Error, it does not meet the constraint!const notOK: { length: number; }2021const longStr = longTwo("aa", "b"); // bconst longStr: "aa" | "b"
here we’d like to get a property from an object given its name. We’d like to ensure that
we’re not accidentally grabbing a property that does not exist on the obj
, so we’ll place a constraint between the two types:
1function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {2return obj[key];3}45let x = { a: 1, b: 2, c: 3, d: 4 };67getProperty(x, "a");8getProperty(x, "m");
Using interfaces:
1interface Lengthwise {2length: number;3}45function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {6console.log(arg.length); // Now we know it has a .length property, so no more error7return arg;8}
More functions? No problem:
1interface GenericIdentityFn<Type> {2(arg: Type): Type;3}45function identity<Type>(arg: Type): Type {6return arg;7}89let myIdentity: GenericIdentityFn<number> = identity;1011myIdentity(1); // ok12myIdentity("1"); // not ok
Take a fetch function example:
1interface ObjOne {2prop1: string;3prop2: number;4}56interface ObjTwo {7prop3: string;8prop4: string;9}1011// Defining our function with a generic type (T)12async function fetchData<T>() {13const response = await fetch("API_URL");14// Telling TS that the response data is the type of T (our generic)15const data = (await response.json()) as T;1617return data;18}1920await fetchData<ObjOne>(); // The returned data would have a type of ObjOne21await fetchData<ObjTwo>(); // The returned data would have a type of ObjTwo
Extracting Keys from an Object
We can extract keys from an object by using default private generics. Hence, _ExtractedKeys
is set to a default contraint.
1type Obj = {2a: "a",3b: "b",4aTwo: "another a"5bTwo: "another b"6}78type ValuesOfKeysStartingWIthA<Obj, _ExtractedKeys extends keyof Obj = Extract<keyof Obj, `a${string}`>> = {9[K in _ExtractedKeys]: Obj[K]10}[_ExtractedKeys];1112type NewUnion = ValuesOfKeysStartingWIthA<Obj>;type NewUnion = "a" | "another a"
Generic Classes
Here is a blueprint of a generic class:
1class GenericClass<T> {2// ...34thisIsAProp: (x: T, y: T) => T; // prop56thisIsAMethod(x: T): T { // method7return x;8}910thisIsArrowFunc = (x: T, y: T) => { // arrow func11console.log();12}13}
Let’s build a minimal generic class:
1class ExampleClass<T> {2private values: T[] = [];34setValue(value: T): void {5this.values.push(value);6}78getValues(): T[] {9return this.values;10}11}1213const example = new ExampleClass<number>();14example.setValue(24);15example.setValue(42);1617const values: number[] = example.getValues();[24, 42]
Example of Using Class Types in Generics:
1function create<T>(c: { new (): T }): T {2return new c();3}
This is a minimal factory method, the creattion is possible due to the Bee
and Lion
meeting the constraint.
1class BeeKeeper {2hasMask: boolean = true;3}45class ZooKeeper {6nametag: string = "Mikle";7}89class Animal {10numLegs: number = 4;11}1213class Bee extends Animal {14numLegs = 6;15keeper: BeeKeeper = new BeeKeeper();16}1718class Lion extends Animal {19keeper: ZooKeeper = new ZooKeeper();20}2122function createInstance<A extends Animal>(c: new () => A): A {23return new c();24}2526const objOne = createInstance(Lion).keeper;const objOne: ZooKeeper27const objTwoHasMask = createInstance(Bee).keeper.hasMask;const objTwoHasMask: boolean
Using generics with interfaces
As shown before, we can use generics with interfaces to define custom types for the properties of the object the interface describes. Here are more examples:
1interface MetaData {2sex: string;3height: "tall" | "short";4favouriteNumber: number;5}67// Defining our generic8interface Person<T> {9id: number;10name: string;11age: number;12metadata: T;13}1415// Using our generic16const personOne: Person<(number|string)[]> = {17id: 1,18name: 'Jeff',19age: 31,20metadata: ['male', 'tall', 22]21}2223// Using our generic24const personTwo: Person<MetaData> = {25id: 1,26name: 'Jeff',27age: 31,28metadata: {29sex: 'female',30height: 'tall',31favouriteNumber: 45,32}33}
the same applies to alias:
1type MetaData = {2sex: string;3height: "tall" | "short";4favouriteNumber: number;5}67type Person<T> = {8id: number;9name: string;10age: number;11metadata: T;12}1314const personOne: Person<(number|string)[]> = {15id: 1,16name: 'Jeff',17age: 31,18metadata: ['male', 'tall', 22]19}2021const personTwo: Person<MetaData> = {22id: 2,23name: 'Jess',24age: 28,25metadata: {26sex: 'female',27height: 'tall',28favouriteNumber: 45,29}30}
Dynamic function arguments with Generics
Consider the following:
This type Event
can be inferred as either LOG_IN
wiht its optional payload or SIGN_OUT
. The problem here is that
TS is accepting this, so there is no strict rules telling us that we can pass, say sendEvent("SIGN_OUT", {});
. Take a moment to read the code:
1export type Event =2| {3type: "LOG_IN";4payload: {5userId: string;6};7}8| {9type: "SIGN_OUT";10};1112const sendEvent = (eventType: Event["type"], payload?: any) => {};1314// Correct15sendEvent("SIGN_OUT");16sendEvent("LOG_IN", { userId: "123" });1718// Wrong - passing incorrect/invalid payload19sendEvent("SIGN_OUT", {});20sendEvent("LOG_IN", { userId: 123 });21sendEvent("LOG_IN", {});22sendEvent("LOG_IN");
To fix this, we can once again use Generics. It helps ensure that the function’s arguments are type-safe based on the event type passed to it.
-
...args
: This uses the rest parameter syntax, allowing the function to accept a variable number of arguments. The type ofargs
is determined conditionally based on theType
value. -
Extract<Event, { type: Type }>
: This extracts the subset of theEvent
union type where the type matches the genericType
. This way, we can narrow down the specific event type from a union of event types.
extends { payload: infer TPayload } ? [type: Type, payload: TPayload] : [type: Type]
: This checks if the extracted event type has a payload property.
If it does (extends { payload: infer TPayload }
), the function expects two arguments: the type
and the payload
.
If it doesn’t, the function expects only one argument: the type
.
1const sendEvent = <Type extends Event["type"]>(2...args: Extract<Event, { type: Type }> extends { payload: infer TPayload }3? [type: Type, payload: TPayload]4: [type: Type]5) => {};
You can also do more cool stuff such as a key remover:
1export const makeKeyRemover =2<Key extends string>(keys: Key[]) =>3<Obj>(obj: Obj): Omit<Obj, Key> => {4return {} as any;5};67const keyRemover = makeKeyRemover(['a']);89const newObject = keyRemover({const newObject: Omit<{ a: number; b: number; c: number; }, "a">10a: 1,11b: 2,12c: 3,13});1415newObject.b16newObject.c
Exercise: Convert Classess to Generic Classes
My challenge to you is to convert this code to using Generics using T
as your type.
Hint: Start by typing InMemoryDatabase<T>
1interface Database {2get(id: string): string;3set(id: string, value: string): void;4}56interface Persistable {7saveToString(): string;8restoreFromString(storedState: string): void;9}1011class InMemoryDatabase implements Database {12protected db: Record<string, string> = {};1314get(id: string): string {15return this.db[id];16}1718set(id: string, value: string): void {19this.db[id] = value;20}21}2223class PersistantMemoryDB extends InMemoryDatabase implements Persistable {24saveToString(): string {25return JSON.stringify(this.db);26}27restoreFromString(storedState: string): void {28this.db = JSON.parse(storedState);29}30}
Solution:
1interface Database<T> {2get(id: string): T;3set(id: string, value: T): void;4}56interface Persistable {7saveToString(): string;8restoreFromString(storedState: string): void;9}1011class InMemoryDatabase<T> implements Database<T> {12protected db: Record<string, T> = {};1314get(id: string): T {15return this.db[id];16}1718set(id: string, value: T): void {19this.db[id] = value;20}21}2223class PersistantMemoryDB<T> extends InMemoryDatabase<T> implements Persistable {24saveToString(): string {25return JSON.stringify(this.db);26}27restoreFromString(storedState: string): void {28this.db = JSON.parse(storedState);29}30}3132const myDb = new PersistantMemoryDB<number>();33myDb.set("123", 1);
Exercise: Remove Prefix
Consider removing the url
prefix. The type DesiredShape
should return a type without the prefix url
.
1interface ApiData {2"url:some-link": string;3"url:some-link-two": string;4}56type RemoveUrlFromObj<T> = {7[K in keyof T]: T[K]8}9type DesiredShape = RemoveUrlFromObj<ApiData>;type DesiredShape = { "url:some-link": string; "url:some-link-two": string; }
Solution:
1interface ApiData {2"url:some-link": string;3"url:some-link-two": string;4}56type RemoveUrls<T> = T extends `url:${infer U}` ? U : T;78type RemoveUrlFromObj<T> = {9[K in keyof T as RemoveUrls<K>]: T[K]10}11type DesiredShape = RemoveUrlFromObj<ApiData>;type DesiredShape = { "some-link": string; "some-link-two": string; }