Neovov
Neovov

Reputation: 349

How to add a constraint on a map in a generic?

Edit: I wasn't clear enough, you might want to jump to the next example.

I've got an issue with constraints on generics and cannot grasp what I'm doing wrong.
Basically, I'm trying this:

enum Categories {
    FIRST = 'first',
    SECOND = 'second',
}

type ItemsMap = {
    [key in Categories]: Item<key>;
}

class Item<
    T extends keyof M,
    M extends {[key in T]: Item<key, M>} = ItemsMap,
> {
    category: T;
    items: M;
}

The goal is to pass a "map" of enum/type (Item will later need to use the "type") which seems to work great because VSCode shows me this:

type ItemsMap = {
    first: Item<Categories.FIRST, ItemsMap>;
    second: Item<Categories.SECOND, ItemsMap>;
}

Yet, I've got a TS error on the default of the M generic:

Type 'ItemsMap' does not satisfy the constraint '{ [key in T]: Item<key, ItemsMap>; }'

Why doesn't it satisfy the constraint?

I've got another issue when trying to use this map in a subclass:

class Foo<M extends {[key in keyof M]: Item<key, M>}> {} // OK
class Bar<M extends ItemsMap = ItemsMap> extends Foo<M> {} // Not OK
class Baz extends Foo<ItemsMap> {} // OK, but why?

TS yield an error on Bar:

Type 'M' does not satisfy the constraint '{ [key in keyof M]: Item<key, M>; }'.
    Type 'ItemsMap' is not assignable to type '{ [key in keyof M]: Item<key, M>; }'.

But I don't understand why. Is there a way to have more information from TS?


Let's use another example, it might be better to understand my issue.
Let's take an event:

interface EventInterface {
    target: EventTargetInterface;
    type: string;
}

So far it's pretty simple, an event have a type and a target (the object that emitted the event). The later is described as follows:

interface EventTargetInterface {
    addEventListener(type: string, listener: (event: EventInterface) => void): void;
    dispatchEvent(event: EventInterface): boolean;
    removeEventListener(type: string, listener: (event: EventInterface) => void): void;
}

The addEventListener method, for example, is callable with a string and a function taking something like an EventInterface.

From that point, I want to add some constraints for multiple reasons:

To do so, I want the developer to define a mapped type:

type EventsMap = {
    first: NiceEvent;
    second: AwesomeEvent;
}

This mapped type is here to say: "For the event type 'first', it will dispatch a 'NiceEvent'". This should be only a type and not generating code.

So, I changed my EventTargetInterface like this:

interface EventTargetInterface<
    M extends {[key in keyof M]: EventInterface},
> {
    addEventListener<T extends keyof M>(type: T, listener: (event: M[T]) => void): void;
    dispatchEvent<T extends M[keyof M]>(event: T): boolean;
    removeEventListener<T extends keyof M>(type: T, listener: (event: M[T]) => void): void;
}

So far, so good, this should constraint the type to only keys of the "given" map and the listener will be tied to it. But now, EventTargetInterface takes a generic, so I have to change EventInterface as well:

interface EventInterface<
    M extends {[key in keyof M]: EventInterface<M>},
> {
    target: EventTargetInterface<M>;
    type: keyof M;
}

OK, looking good. Let's add a base implementation now:

abstract class EventBase<
    M extends {[key in keyof M]: EventInterface<M>},
> implements EventInterface<M> {
    target: EventTargetInterface<M>;
    type: keyof M;

    constructor(type: keyof M) {
        this.type = type;
    }
}

abstract class EventTargetBase<
    M extends {[key in keyof M]: EventInterface<M>},
> implements EventTargetInterface<M> {
    addEventListener<T extends keyof M>(type: T, listener: (event: M[T]) => void): void {}
    dispatchEvent<T extends M[keyof M]>(event: T): boolean { return false; }
    removeEventListener<T extends keyof M>(type: T, listener: (event: M[T]) => void): void {}
}

And now, the first concrete implementation:

enum MyEvents {
    FIRST = 'first',
    SECOND = 'second',
}

type MyEventsMap = {
    [key in MyEvents]: MyEvent;
}

class MyEvent<
    M extends MyEventsMap = MyEventsMap,
> extends EventBase<M> {}

class MyEventTarget<
    M extends MyEventsMap = MyEventsMap,
> extends EventTargetBase<M> {}

And this is here that I have an issue (on extends EventBase<M> and EventTargetBase<M>):

Type 'M' does not satisfy the constraint '{ [key in keyof M]: EventInterface<M>; }'.
  Type 'MyEventsMap' is not assignable to type '{ [key in keyof M]: EventInterface<M>; }'.ts(2344)

So, for Typescript something extending MyEventsMap does not conform to M extends {[key in keyof M]: EventInterface<M>}. But I don't understand because MyEvent extends EventBase which implements EventInterface!

Even more confusing, using:

class MyEvent extends EventBase<MyEventsMap> {}

This is fine for Typescript, so I don't get what is going wrong when using the generic. (I need to keep the generic as I want my class to be extendable but that's another topic)

You can access the Typescript Playground if you want to fiddle with it.

Upvotes: 3

Views: 947

Answers (3)

jabuj
jabuj

Reputation: 3639

UPD

Ok, now I understand what you want, but there are still some questions about how your objects are supposed to behave at runtime. Also you have a lot of circular dependency going on. It's very hard to deal with, and, honestly, I've got no idea what typescript is trying to do with your code.

The main problem that prevents me from making up a reasonable answer is not the generics, but object properties that produce indefinite nesting. Mainly, the target property on EventInterface interface, what is it supposed to be? Is it the same EventTargetInterface that dispatched the event? Is it another EventTargetInterface? In both cases, how do you initialize the target property? So I was actually able to make your example (kinda?) work in TS sandbox, but you should really think of changing your design in some way. These circular dependencies are really hard to deal with, and I have no idea if this will work as expected in all cases, these generics are very unstable. This can cause the developers more headache than help.

Reason of the error

So the main reason lies in the end of error, it says

'MyEventsMap' is assignable to the constraint of type 'M', but 'M' could
be instantiated with a different subtype of constraint 'MyEventsMap'.

For example, take the following function

function fn<T extends string | number>(): T {
  return "I don't work"
}

This will produce a similar error

'"I don't work"' is assignable to the constraint of type 'T', but 'T'
could be instantiated with a different subtype of constraint 'string | number'.

The reason should be obvious, T is not guaranteed to include string type, I could as well call it as fn<number>() or fn<1>() and it won't work. The thing about typescript that caused me a lot of pain in the neck is the fact you cannnot prevent calling function/instantiating class/using type alias/do anything concerning generics with a subtype of the contraint in extends clause. This is not completely true, there is a hack, using helper types like

type EnsureMyMap<T> = T extends MyEventsMap<infer M> ? MyEventsMap<M> : never

I've posted a question on this topic. It's not exactly suitable for this case, but it shows the idea. You can check it out and read the best answer for a concrete example.

Honestly, no idea, how this applies to your particular example, and I have no wish to dig into it. Maybe it is because of the default value you specify for the generic in MyEvent and MyEventTarget, but I'm not sure. You probably could fix everything with this infer keyword and helper types after an hour or two of struggling, but is it worth it?

So my suggestion would be to rethink the design of these classes. Particularly it would be nice to get rid of the target property on the event, which causes all of that circular dependency stuff. Maybe you don't need it? Maybe you could pass it alongside with the event? Another option would be to go less strict with the contraints and reduce headache in the future at the cost of worse type hints (I mean getting rid of this event map entirely). I may update the answer again, if you need help on redesigning all of that, I just need a runtime example, not only ambient type declaration. That's all I can suggest for now.

Original answer

Your code seems a bit strange. There are lots of circular references. Firstly, I suppose, that in the second code snippet, the type should have a different name, otherwise ItemsMap, as suggested in the comments by Inigo, references itself:

type MyMap = {
    first: Item<Categories.FIRST, ItemsMap>;
    second: Item<Categories.SECOND, ItemsMap>;
}

Even if this is the case, I don't think, that even if the code you wrote worked, it would do what it is supposed to do. Suppose you make a variable

const map: MyMap = { first: /*...*/, second: /*...*/ }

So what is map.first supposed to be? An object like

{ 
  category: Categories.FIRST, 
  items: /*...*/
}

So what is map.first.items? An ItemsMap, for example { [Category.FIRST]: /*...*/ }. So what is map.first.items[Category.FIRST]? An object like

{
  category: Category.FIRST,
  items: /* ... */
}

Now go back to the previous paragraph and another [Category.FIRST].items to the property I wrote the question about.

So that just goes on forever. It does stop, if you assign an empty object to items at one point, but anyways it doesn't seem like you want an indefinitely nested tree of items and categories. Sorry if you do, it's just not clear from you code and explanations.

So the only thing I can suppose is that you didn't what to use Item class in the definition of ItemsMap. In this case it would make some sense for me. You just stated

The goal is to pass a "map" of enum/type

So I think you mean you want to make an object something like this (if Item in ItemsMap definition is replaced with { id: key }, for example):

const map = {
  first: {
    category: 'first',
    items: { 
      first: { id: 'first' },
      second: { id: 'second' },
    },
  },
  second: {
    category: 'second',
    items: {
      first: { id: 'first' },
      second: { id: 'second' },
    },
  },
}

Even here, it doesn't make much sense to me, why you want your keys to be from the same set in map object and all map[...].items objects. Again, sorry if that's not what you meant, I will update my answer if you provide a better explanation of your goals. Also show an example of an object of the type you want to define.

Upvotes: 2

Ernesto
Ernesto

Reputation: 4272

well an easy way to have a typed key map is by:

type Categories = "SECOND" | "FIRST";

const myMap = new Map<Categories, string>();
myMap.set("FOURTH", "Fourth");
myMap.set("FIRST", "Fourth");
myMap.set("SECOND", "Fourth");

enter image description here

Upvotes: 0

amakhrov
amakhrov

Reputation: 3939

I'm not sure I completely understand the end goal (perhaps a few more usage examples could help). However, the way I currently interpret the task... does this solve it?

class Item<
    T extends string,
    K extends string = Categories,
> {
    category: T;
    items: Record<K, Item<T, K>>;
}

Upvotes: 0

Related Questions