Our code gets more readable if we make it more abstract. We don’t need to know how they work as long as it works. Many users don’t know how EC site works and don’t have to know it. We can say the same thing to development. Detail information should be hidden by encapsulation. Generics is useful to make our code abstract and remove duplicated code. Let’s see how to use generics in Typescript.
Generic arguments/parameters in function
Let’s start with simple examples. We have two functions. One requires string and another requires number.
function func(value: string): string {
return value;
}
function func2(value: number): number {
return value;
}
const str = func("123");
console.log(str.length);
// 3
const num = func2(123);
// console.log(num.length);
// Property 'length' does not exist on type 'number'.ts(2339)
Compiler complains when using num.length
because func2
returns number that doesn’t have length
property. However, the two functions are exactly the same except for the data type. It’s code duplication that we want to avoid. Let’s refactor it.
function func3(value: unknown): unknown {
return value;
}
function func4(value: any): any {
return value;
}
const unknownResult = func3(123);
if (!Object.prototype.hasOwnProperty.call(unknownResult, "length")) {
console.log("unknownResult doesn't have length property.")
}
console.log(func4(123).length);
// undefined
If we don’t know which data type the function receives we can specify either unknown
or any
data type. However, we have to write additional code for unknown
to check if it has desired property. If any
is used compiler doesn’t say anything and it receives unexpected value at runtime. We don’t know if it works until the code runs. any
shouldn’t be used if possible. Otherwise… Why are you using Typescript? We can solve this problem by generics.
function func5<T>(value: T): T {
return value;
}
console.log(typeof func5(12));
console.log(typeof func5("123"));
console.log(func5("123").length);
// number
// string
// 3
Typescript can understand which data type is specified in the function. Therefore, You can immediately recognize an error at coding time. T
accepts interface, type and class as well. Following is an example for type.
type myType = { hoge: string, foo: string };
const hogeFoo: myType = { hoge: "hoge", foo: "foo" };
console.log(typeof func5(hogeFoo));
console.log(func5(hogeFoo).foo);
console.log(func5(hogeFoo).hoge);
// object
// foo
// hoge
Generic interface and class
Let’s assume that we call a function that returns only active data. Let’s assume two cases.
- It provides us a member list who currently work in our workplace.
- It provides us a item list that are still in our shop floor.
For example, we need to create two modules to know
- Who quits and who starts working there
- Which item is new and which item is sold out
Let’s create the interface and abstract class for that.
export interface ReturnDataType<T> {
addedItems: T[];
deletedItems: T[];
}
export abstract class DataHolder<T, K extends number | string> {
protected currentItems = new Map<K, T>();
public process(receivedItems: T[]): ReturnDataType<T> {
const addedItems: T[] = [];
const deletedItems: T[] = [];
receivedItems
.filter((item) => this.isAdded(item))
.forEach((receivedItem) => {
addedItems.push(receivedItem);
this.currentItems.set(this.getId(receivedItem), receivedItem);
});
Array.from(this.currentItems.values())
.filter((currentItem) => this.isDeleted(receivedItems, currentItem))
.forEach((currentItem) => {
deletedItems.push(currentItem);
this.currentItems.delete(this.getId(currentItem));
});
return { addedItems, deletedItems };
}
protected abstract isAdded(receivedItem: T): boolean;
protected abstract isDeleted(receivedItems: T[], currentItem: T): boolean;
protected abstract getId(item: T): K;
}
DataHolder
stores active items in currentItems
. process
function requires the current active items to update the data. If the new item list is different from previous one it shows added items and deleted items. We can constrain the data type specified for the generic type parameter by using extends
keyword K extends number | string
. K
is number, string or extended type based on number/string. It means that Map class uses either number or string as key.
The conditions for isAdded
, isDeleted
and getId
can be different for each case, so those 3 functions are abstract with generic type parameter T
. We can define the conditions as we want.
This is for member list.
import { DataHolder } from "./DataHolder";
interface Person {
name: string;
employeeId: number;
isFired: boolean;
};
class MemberHolder extends DataHolder<Person, number>{
protected isAdded(receivedItem: Person): boolean {
return !receivedItem.isFired
&& !this.currentItems.has(this.getId(receivedItem));
}
protected isDeleted(receivedItems: Person[], currentItem: Person): boolean {
return !receivedItems.some((item) =>
!item.isFired &&
this.getId(item) === this.getId(currentItem));
}
protected getId(item: Person): number {
return item.employeeId;
}
}
const holder = new MemberHolder();
const members = {
yuto: { name: "yuto", employeeId: 1, isFired: false },
john: { name: "john", employeeId: 2, isFired: false },
ralph: { name: "ralph", employeeId: 3, isFired: false },
gon: { name: "gon", employeeId: 4, isFired: true },
};
console.log(holder.process([members.yuto, members.john]));
// {
// addedItems: [
// { name: 'yuto', employeeId: 1, isFired: false },
// { name: 'john', employeeId: 2, isFired: false }
// ],
// deletedItems: []
// }
console.log(holder.process([members.yuto, members.john, members.gon]));
// { addedItems: [], deletedItems: [] }
console.log(holder.process([members.ralph, members.john, members.gon]));
// {
// addedItems: [ { name: 'ralph', employeeId: 3, isFired: false } ],
// deletedItems: [ { name: 'yuto', employeeId: 1, isFired: false } ]
// }
And this is for item list.
import { DataHolder } from "./DataHolder";
interface Product {
id: string;
color: string;
name: string;
price: number;
}
class ProductHolder extends DataHolder<Product, string>{
protected isAdded(receivedItem: Product): boolean {
return !this.currentItems.has(this.getId(receivedItem));
}
protected isDeleted(receivedItems: Product[], currentItem: Product): boolean {
return !receivedItems.some((item) => this.getId(item) === this.getId(currentItem));
}
protected getId(item: Product): string {
return `${item.id}_${item.color}`;
}
}
const holder = new ProductHolder();
const products = {
black: { id: "desk", color: "black", name: "super-desk", price: 100 },
yellow: { id: "desk", color: "yellow", name: "super-desk", price: 99 },
white: { id: "desk", color: "white", name: "super-desk", price: 122 },
green: { id: "desk", color: "green", name: "super-desk", price: 87 },
};
console.log(holder.process([products.black, products.green]));
// {
// addedItems: [
// { id: 'desk', color: 'black', name: 'super-desk', price: 100 },
// { id: 'desk', color: 'green', name: 'super-desk', price: 87 }
// ],
// deletedItems: []
// }
console.log(holder.process([products.black, products.green, products.yellow]));
// {
// addedItems: [ { id: 'desk', color: 'yellow', name: 'super-desk', price: 99 } ],
// deletedItems: []
// }
console.log(holder.process([products.yellow, products.white]));
// {
// addedItems: [ { id: 'desk', color: 'white', name: 'super-desk', price: 122 } ],
// deletedItems: [
// { id: 'desk', color: 'black', name: 'super-desk', price: 100 },
// { id: 'desk', color: 'green', name: 'super-desk', price: 87 }
// ]
// }
Both classes have the same logic in the base class DataHolder
but each class stores different data type as you can see on the output. I defined small conditions for each case but behavior is the same in both cases.
Summry
Generics can make our code abstract and reduce code volume and code duplication. It’s more readable and maintainable. Try to find common logic consciously to extract it into abstract function while reading code.
The basic template for generics is following.
interface InterfaceName<T>(parameter: T) { prop: T;}
interface InterfaceName<T extends your-data-type >(parameter: T) { prop: T;}
Comments