Making your code more abstract by Generics in Typescript

JavaScript/TypeScript

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.

Sponsored links

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

Copied title and URL