Create a notification tree with strong type prompt in typescript
Tags: typescript, webdev
Published: 2022-08-02 09:31:16

In almost all modern applications, we can see the notification badge. While we click the button with badge, the subdivision notification badge will appear. It just like a path that guide you to the original notification place. Therefore, we can use tree data struct to build a class that can represent our notification struct.

about one year and a half ago, I publish a package about we talk above: [red-manager](https://www.npmjs.com/package/red-manager). But there are some disadvantages:

- Missing type prompt for path;
- The source code is tooo complicate;

The reason for these problems is that it just migrated from a javascript file.

Now in order to make it more powerful, I use string literal union type to strengthen type prompt, it make code more simpler at the same time.

Let's see how it look first:

```typescript
// define the root node at first.
const root = NotificationTree.root(['notification', 'me']);
```


![typing in vscode](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qsjx8mpj5x74rnd5s7yb.gif)



The rest code.

```typescript
const notification = root.expand('notification', ['comments', 'likes', 'something']);
notification.getChild('likes').setValue(20);
const something = notification.getChild('something');
something.setValue(10);
root.dump();
```


The `dump` function can print tree in console. It's looks like:


![console](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ezhtcuioub87c4pfq1oq.jpg)


The Generic definition of this class is:

```typescript
class NotificationTree<Prev extends string = string, Curr extends string = string> {
    // ...
}
```

The `Prev` represent ancestors path, and `Curr` represent its children name. Well, maybe this description is not very intuitive. Let's take a look about `root`'s type signature:

```typescript
const root: NotificationTree<"@ROOT", "notification" | "me">
```

As a root node, the first generic `"@ROOT"` is its name. The second generic: `"notification" | "me"` is a union type that includes all its children's name.

After defined the root node, we use the `expand` function to "grow the tree". The IntelliSense will provide and limit all the branch's name we can expand while we typing the first parameter. We will not be afraid of typo :)

```typescript
const notification = root.expand('notification', ['comments', 'likes', 'something']);
```

The second parameter is a string array which represent all children's name of "notification" sub tree. Let's see its type signature.

```typescript
const notification: NotificationTree<"@ROOT/notification", "comments" | "likes" | "something">
```

It looks similar to root's signature, but the difference is that the first generic type is a template string and the value is its a path from ancestor to itself.

The full code:

> This file is written in [`wepo-project/web-client`](https://github.com/wepo-project/web-client/blob/main/src/utils/notificationTree.ts), if you are interested, welcome to check it out!

```typescript
const ROOT_NAME = "@ROOT";
type SPLIT = "/";
type JoinType<A extends string, B extends string> = `${A}${SPLIT}${B}`
type Callback = (val: number) => void;

export type ExtractType<Type> = Type extends NotificationTree<infer P, infer C> ? NotificationTree<P, C> : never;
export type ExtractPrev<Type> = Type extends NotificationTree<infer P, string> ? P : never;
export type ExtractCurr<Type> = Type extends NotificationTree<string, infer C> ? C : never;

/**
 * NotificationTree
 * The `ROOT` instance can only be instantiated once.
 * Prev: ancestors path
 * Curr: children names
 */
export class NotificationTree<Prev extends string = string, Curr extends string = string> {
    static ROOT: NotificationTree | null = null;

    name: string;
    private parent: NotificationTree | null;
    private children: Record<Curr, NotificationTree>;
    private _value: number = 0;
    private listener: Map<Callback, { target: any, once: boolean }>;

    /**
     * Create an root node of NotificationTree
     * also can create by constructor
     * @returns Root Node
     */
    static root<Name extends string = string>(children: Name[]): NotificationTree<typeof ROOT_NAME, Name> {
        if (this.ROOT) {
            console.error(`can not create root node duplicatly`)
            return this.ROOT
        } else {
            const _root = new NotificationTree(null, ROOT_NAME, children);
            this.ROOT = _root;
            return _root;
        }
    }

    private constructor(parent: NotificationTree<string, string> | null, name: Prev, childrenName: Curr[]) {
        this.parent = parent;
        this.name = name;
        this.expandChildren(childrenName);
        this.listener = new Map();
    }

    /**
     * append children with name list
     * @param childrenName 
     */
    private expandChildren(childrenName: Curr[]) {
        const children: typeof this.children = {} as any;
        for (const name of childrenName) {
            if (children[name] === void 0) {
                children[name] = new NotificationTree(this as any, name, []);
            } else {
                console.warn(`duplicate node name: ${name}`);
            }
        }
        this.children = children;
    }

    get value() {
        return this._value;
    }

    /**
     * use private decorations to prevent external calls to value setters
     */
    private set value(newVal: number) {
        const delta = newVal - this._value;
        if (this.parent) {
            this.parent!.value += delta;
        }
        this._value = newVal;
        try {
            for (const [callback, { target, once }] of this.listener.entries()) {
                callback.call(target, newVal);
                if (once) {
                    this.unListen(callback);
                }
            }
        } catch (e) {
            // use try-catch to prevent break the setter chain
            console.error(e);
        }
    }

    /**
     * append children to this node with specify name list
     * @param which 
     * @param names 
     * @returns 
     */
    expand<Name extends string, WhichOne extends Curr>(which: WhichOne, names: Name[]): NotificationTree<JoinType<Prev, WhichOne>, Name> {
        this.children[which].expandChildren(names);
        return this.children[which];
    }

    /**
     * set value, it will trigger ancestors' changed their value.
     * make sure it's leaf node otherwise value will out of sync.
     * @param value 
     */
    setValue(value: number) {
        if (Object.keys(this.children).length) {
            console.warn(`this node has children, set it's value can't keep the values consistent`)
        }
        this.value = value;
    }

    /**
     * get children by name.
     * make children field private in order to prevent children modified external.
     * to prevent 
     * @param childName 
     * @returns 
     */
    getChild<N extends Curr, C extends string>(childName: N): Omit<NotificationTree<JoinType<Prev, N>, C>, 'getChild'> {
        if (childName in this.children) {
            return this.children[childName]
        } else {
            throw new Error(`${childName} is not [${this.name}]'s child`);
        }
    }

    /**
     * subscribe value changed event
     * @param callback 
     * @param options target: context, once: cancel once triggered
     * @returns a handler to unsubscribe event
     */
    listen(callback: Callback, options: {
        target?: any,
        once: boolean,
    } = { target: null, once: false }) {
        this.listener.set(callback, { target: options.target, once: options.once });
        return { cancel: () => this.unListen(callback) }
    }

    /**
     * unsubscribe value changed event
     * @param callback
     */
    unListen(callback: Callback) {
        this.listener.delete(callback);
    }

    /**
     * dump value to console to show value intuitively in console
     */
    dump() {
        console.groupCollapsed(`${this.name} -> ${this.value}`);
        for (const key in this.children) {
            const child = this.children[key];
            if (Object.keys(child.children).length) {
                child.dump();
            } else {
                console.log(`${child.name} -> ${child.value}`)
            }
        }
        console.groupEnd();
    }
}
```