1

I want to use vistor pattern to make sure that all cases are handled when I add a new enum value. Enum example:

export enum ActionItemTypeEnum {
    AccountManager = 0,
    Affiliate = 4,
}

Currently, I'm using the following approach:

interface IActionItemTypeVisitor<T> {
    accountManager(value: ActionItemTypeEnum.AccountManager): T;
    affiliate(value: ActionItemTypeEnum.Affiliate): T;
}

// generates compiler error when case is missing:
const visitActionItemType = <T>(value: ActionItemTypeEnum, visitor: IActionItemTypeVisitor<T>) => {
    // eslint-disable-next-line
    switch (value) {
        case ActionItemTypeEnum.None:
            return visitor.none(value);
        case ActionItemTypeEnum.AccountManager:
            return visitor.accountManager(value);
    }
};

// ActionItemPermissionVisitor implements IActionItemTypeVisitor<Permission>
const permission = visitActionItemType(this.type, new ActionItemPermissionVisitor())

I'm looking for a more idiomatic TypeScript solution if that exists.


This is final solution:

export type IActionItemTypeVisitor<T> = {
    [K in keyof typeof ActionItemTypeEnum as Uncapitalize<K>]: (value: (typeof ActionItemTypeEnum)[K]) => T;
};

export const visitActionItemType = <T>(value: ActionItemTypeEnum, visitor: IActionItemTypeVisitor<T>): T => {
    const key: string = ActionItemTypeEnum[value]?.[0].toLowerCase() + ActionItemTypeEnum[value]?.substring(1);
    return visitor[key]?.(value);
};

export class ActionItemVisitor implements IActionItemTypeVisitor<string> {
    public accountManager(value: ActionItemTypeEnum.AccountManager): string {
        return 'AccountManager';
    }

    public affiliate(value: ActionItemTypeEnum.Affiliate): string {
        return 'Affiliate';
    }
}

const result: string = visitActionItemType(ActionItemTypeEnum.AccountManager, new ActionItemVisitor());
kemsky
  • 14,727
  • 3
  • 32
  • 51
  • 1
    So what's the question? – Philipp Meissner Jan 27 '23 at 14:52
  • I want a more idiomatic TypeScript solution if that exists. – kemsky Jan 27 '23 at 15:34
  • For starters, your example code given doesn't work in the playground. Your interface defines `accountManager`, but your code uses `visitor.AccountManager`, which doesn't exist. Similarly, you use `visitor.None`, which also doesn't exist on the interface. Could you fix these inconsistencies? – kelsny Jan 30 '23 at 15:07
  • What's wrong with this approach? I think it is the best since it relies on the compiler to do the work, but you could always try something similar to [this question](https://stackoverflow.com/q/75152029/18244921). – kelsny Jan 30 '23 at 15:10
  • @vera. compiler works, no question, but each time I add a new enum value I have to modify visitor interface and visitor function, I suspect that it is possible to derive necessary types from the enum itself, so that I will only have to modify concrete visitors. – kemsky Jan 30 '23 at 16:52
  • I suppose you could do [this](https://tsplay.dev/wOxolN) but getting rid of that switch statement will be ugly. – kelsny Jan 30 '23 at 17:10
  • possible duplicate of https://stackoverflow.com/questions/39419170/how-do-i-check-that-a-switch-block-is-exhaustive-in-typescript – Antoine Eskaros Feb 01 '23 at 08:50

2 Answers2

1
TLDR; take a look to the following stackblitz example, there's more comments there:

https://stackblitz.com/edit/angular-cqbtzd?file=src/main.ts


This can be done using typescript advanced types.

First you define away to obtain the expected method names, the following type will modify it's parameter first letter to lowercase:

type FirstLetterLowercase<S> = S extends `${infer C}${infer R}`
  ? `${Lowercase<C>}${R}`
  : never;

Then you need to get the current one to parse them using FirstLetterLowercase, note that K in keyof typeof ActionItemTypeEnum is used to obtain the enum name:

type TActionItemTypeVisitor<T> = {
  [K in keyof typeof ActionItemTypeEnum as FirstLetterLowercase<K>]: (
    value: typeof ActionItemTypeEnum[K]
  ) => T;
};

Also, to get rid of your switch/case you can just call the function directly inside the visitor object:

// doesn't generate compiler error when case is missing
visitActionItemType<T>(
value: ActionItemTypeEnum,
visitor: TActionItemTypeVisitor<T>
) {
  // note that this won't work with single letter enum values
  return visitor[ActionItemTypeEnum[value][0].toLowerCase() + ActionItemTypeEnum[value].substring(1)](value);
}

Thats's it... Later you can even implement your own interface using the declared type:

interface IActionItemTypeVisitor
  extends TActionItemTypeVisitor<string[]> { // <-- this example show an `string[]` implementation
  specialMethodOnlyForStringArray: () => void;
}

...

/**
 * Demo implementation for `string[]`
 *
 * This one generates compiler error when method implementation is missing.
 */
export class ActionItemPermissionVisitor implements IActionItemTypeVisitor {
  none(value: ActionItemTypeEnum): string[] {
    return [];
  }
  accountManager(value: ActionItemTypeEnum) {
    return ['permission1', 'permission2'];
  }
  affiliate(value: ActionItemTypeEnum) {
    return ['permission3'];
  }
  // method just for demo
  specialMethodOnlyForStringArray() {
    console.log('do something special :)');
  }
}
...
this.permission = this.visitActionItemType(
  this.type,
  new ActionItemPermissionVisitor()
);

For example, if you add an enum value called productManager it'll throw the following error: enter image description here


In case your were thinking about exact types, there's an open feature request for Typescript language to do that; there's a an approach to solve it here.

luiscla27
  • 4,956
  • 37
  • 49
1

Try this:

type IActionItemTypeVisitor<T> = { 
    [key in Uncapitalize<keyof typeof ActionItemTypeEnum>]: (value: ActionItemTypeEnum) => T;
};

This will trigger a warning when any enum case is missing, or when there's unrecognized entry.

Robo Robok
  • 21,132
  • 17
  • 68
  • 126
  • This way compiler forces to use function properties instead of methods, is there any way to overcome it? – kemsky Feb 01 '23 at 16:30