Weird behaviours with generic intersection type

Viewed 142

The title might be a bit off, but I don't know how to describe the problem accurately.

I have the following code:

global.d.ts

type BuiltInListener<T extends Element, Key extends keyof HTMLElementEventMap> = (event: HTMLElementEventMap[Key], target: T) => any;
type Listener<T extends Element, TDetails = any> = (details: TDetails, target: T, event: EventUnion<TDetails>) => any;
type EventUnion<TDetails> = CustomEvent<TDetails>|HTMLElementEventMap[keyof HTMLElementEventMap];

type FilteredEventMap<TEvents> = keyof Omit<HTMLElementEventMap, keyof TEvents|'options'>;

type EventListenerConfig<T extends Element = Element, TEvents extends object = {}> = {
    options?: Options;
    [key: string]: Listener<T>|Options|undefined;
} & {
    [Key in FilteredEventMap<TEvents>]?: BuiltInListener<T, Key>;
} & {
    [Key in keyof Omit<TEvents, 'options'>]?: Listener<T, TEvents[Key]>;
};

interface EventTarget
{
    on<T extends Element = Element, TEvents extends object = {}>(
        selector: string|EventListenerConfig<T, TEvents>,
        settings?: EventListenerConfig<T, TEvents>
    ): EventTarget;

    trigger(event: string): EventTarget;
    emit<TDetail = any>(event: string, detail?: TDetail, composed?: boolean): CustomEvent<TDetail>;
    await<TDetail = any>(event: string): Promise<TDetail>;
}

Examples

// 1
this.shadow.on({
    options: {
        passive: false,
    },
    wheel: (e) => { // <--- inferred as any
    },
});


// 2
const kaas: EventListenerConfig = {
    options: {
        passive: false,
    },
    wheel: (e) => { // <--- inferred as WheelEvent
    },
};

// 3
this.shadow.on<Element>({
    options: {
        passive: false,
    },
    wheel: (e) => { // <--- inferred as WheelEvent
    },
});


// 4
this.shadow.on<Button, { click: { action: string } }>('footer > [action]', {
    click: ({ action }) => {}, // <--- inferred as { action: string }
    wheel: (e) => {}, // <--- inferred as WheelEvent
});

why is it that 1 one gets inferred to any but all the other examples do work?

3 is a copy of 1 where I explicitly set the generic type, but it is the same as the default for that generic type.

4 is just an example of why I even have this ridiculous set up

To me it almost seems as if the compiler does something funky with default generic parameters, and that's why the EventListenerConfig of 1 goes through the [key: string] route instead of the [Key in FilteredEventMap<TEvents>] route.

Playground link

1 Answers

The difference between your 1 example and your 2, 3, and 4 examples is that you are not specifying the generic type parameters when you call on() in 1, whereas you are in the others. Even 2 and 3, where you do not manually specify the type parameters, you are doing so implicitly with the type parameter defaults.

But for example 1, you do not specify the type parameters at all, and instead, the compiler infers them from the values you pass in for the selector and settings arguments. It does not use the type parameter defaults here, unless inference completely fails and it falls back to them.

It looks like the behavior you're looking for is for the compiler not to use type inference at all, and instead use the defaults. But such type inference is a feature that is desirable most of the time. There's no "turn off inference here" switch you can turn.


There is an open suggestion at microsoft/TypeScript#14829 to be able to ask the compiler not to infer a type parameter from a particular site where it appears. Something like NoInfer<T> would mean "this is T, but don't use a value of type NoInfer<T> to infer T". If that feature were implemented, you could change

on<T extends EventTarget = EventTarget, TEvents extends object = {}>(
    selector: string | EventListenerConfig<T, TEvents>,
    settings?: EventListenerConfig<T, TEvents>
): EventListenerConfig<T, TEvents>;

to

on<T extends EventTarget = EventTarget, TEvents extends object = {}>(
    selector: string | EventListenerConfig<T, NoInfer<TEvents>>,
    settings?: EventListenerConfig<T, NoInfer<TEvents>>
): EventListenerConfig<T, TEvents>;

and things would behave better for you, at least for the TEvents type parameter. And while it turns out that there are some ways to define NoInfer<T> that work for some use cases (such as this), there's no official version of it, and even if there were, this feature is geared more toward selective inference and not complete disabling of it.


Instead, my suggestion here would be to add an overload of the on() method, with a more restrictive call signature in which the TEvents type parameter simply does not exist. Anywhere TEvents appeared previously, you'd use the default {}:

interface EventTarget {

    on<T extends EventTarget = EventTarget>(
        selector: string | EventListenerConfig<T, {}>,
        settings?: EventListenerConfig<T, {}>
    ): EventListenerConfig<T, {}>;

    on<T extends EventTarget, TEvents extends object = {}>(
        selector: string | EventListenerConfig<T, TEvents>,
        settings?: EventListenerConfig<T, TEvents>
    ): EventListenerConfig<T, TEvents>;
}

The previous signature is still there as a second overload (although I removed the default on T because it's not necessary anymore; if you want the default for T, then you also want it for TEvents, in which case you want the first call signature).

So now, when you call on() without specifying type parameters, the first thing the compiler will do is try to use that first overload. It will only move onto the second overload if that fails. You can verify that this makes things behave better for your 1 example:

    const ok = this.on({
        wheel: (e) => { }, // <--- inferred as WheelEvent
    });

while not affecting the behavior for your 2, 3, and 4 examples:

    // 2
    const kaas: EventListenerConfig = {
        wheel: (e) => { }, // <--- inferred as WheelEvent
    };

    // 3
    this.on<EventTarget>({
        wheel: (e) => { }, // <--- inferred as WheelEvent
    });


    // 4
    this.on<Button, { click: { action: string } }>('footer > [action]', {
        click: ({ action }) => { }, // <--- inferred as { action: string }
        wheel: (e) => { }, // <--- inferred as WheelEvent
    });
}

Playground link to code

Related