distante
distante

Reputation: 7025

Is it possible to force method overload based on an enum in typescript?

This is probably a continuation of this question. I have an enum

export enum RewardAdPluginEvents {
  Loaded= 'onRewardedVideoAdLoaded',
  FailedToLoad= 'onRewardedVideoAdFailedToLoad',
  Showed= 'onRewardedVideoAdShowed',
  FailedToShow= 'onRewardedVideoAdFailedToShow',
  Dismissed= 'onRewardedVideoAdDismissed',
  Rewarded= 'onRewardedVideoAdReward',
}

An I have an interface that needs overloads like this:

  addListener(
    eventName: RewardAdPluginEvents.Dismissed,
    listenerFunc: () => void,
  ): PluginListenerHandle;

  addListener(
    eventName: RewardAdPluginEvents.FailedToLoad,
    listenerFunc: (error: ErrorInfo) => void,
  ): PluginListenerHandle;
  
  addListener(
    eventName: RewardAdPluginEvents.Rewarded,
    listenerFunc: (item: RewardItem) => void,
  ) : PluginListenerHandle;

Is there a way to force the interface to implement all that overloads?

I tried with

import type { PluginListenerHandle } from '@capacitor/core';

type Enum<E> = Record<keyof E, string> & { [k: string]: string };

export type HasListeners<Enum> = { addListener: (eventName: keyof Enum, listenerFunc: (info: any) => void) => PluginListenerHandle }

and then

export interface RewardDefinitions extends HasListeners<RewardAdPluginEvents>{

but it fails because it takes too many keys that come from an object:

Interface 'RewardDefinitions' incorrectly extends interface 'HasListeners'. Types of property 'addListener' are incompatible. Type '{ (eventName: RewardAdPluginEvents.Dismissed, listenerFunc: () => void): PluginListenerHandle; (eventName: RewardAdPluginEvents.FailedToLoad, listenerFunc: () => void): PluginListenerHandle; (eventName: RewardAdPluginEvents.FailedToShow, listenerFunc: () => void): PluginListenerHandle; (eventName: RewardAdPluginEven...' is not assignable to type '(eventName: number | "toString" | "charAt" | "charCodeAt" | "concat" | "indexOf" | "lastIndexOf" | "localeCompare" | "match" | "replace" | "search" | "slice" | "split" | "substring" | "toLowerCase" | ... 31 more ... | "trimEnd", listenerFunc: (info: any) => void) => PluginListenerHandle'. Types of parameters 'eventName' and 'eventName' are incompatible. Type 'number | "toString" | "charAt" | "charCodeAt" | "concat" | "indexOf" | "lastIndexOf" | "localeCompare" | "match" | "replace" | "search" | "slice" | "split" | "substring" | "toLowerCase" | ... 31 more ... | "trimEnd"' is not assignable to type 'RewardAdPluginEvents.Dismissed'. Type 'number' is not assignable to type 'RewardAdPluginEvents.Dismissed'

Upvotes: 1

Views: 473

Answers (1)

Oblosys
Oblosys

Reputation: 15126

It's possible, but not terribly straightforward. Overloads are intersections of functions, which are trickier to manipulate than unions.

First you'll need something to turn a union into an intersection, which can be done with inference on a contravariant parameter in a conditional type (docs). This is a bit techical, so I'll omit the full explanation here.

type Contra<T> = (x: T) => void
type UnwrapContra<T> = [T] extends [Contra<infer S>] ? S : never
type UnionToIntersection<U> = UnwrapContra<U extends any ? Contra<U> : never>

For example, UnionToIntersection<{a:1}|{b:2}> evaluates to {a:1} & {b:2}. Now you'll need to get a union of all overloads, which is a bit more straightforward. Just declare a type Overload that creates a single overload, and use a conditional type to distribute it over each member of the enum.

type Overload<T> =
  (eventName: T, listenerFunc: (...args: any[]) => any) => PluginListenerHandle
type OverloadUnionForEnum<T> = T extends any ? Overload<T> : never

type OverloadUnion = OverloadUnionForEnum<RewardAdPluginEvents>

The resulting OverloadUnion a union of overloads, one for each member of RewardAdPluginEvents: Overload<RewardAdPluginEvents.FailedToLoad> | Overload<RewardAdPluginEvents.Dismissed> | ..

Turning this union into an intersection yields the actual overloaded function signature for addListener:

type Overloads = UnionToIntersection<OverloadUnion>

With all that out of the way, you can now define a type Validate that constrains its argument to have an addListener property that conforms to the overloads:

type Validate<T extends {addListener: Overloads}> = T

To use Validate, consider two interfaces ListenerA and ListenerB that both contain some of the overloads, and restrict RewardAdPluginEvents to just Dismissed, FailedToLoad, and Rewarded for now to keep the types a bit smaller:

interface ListenerA {
  addListener(
    eventName: RewardAdPluginEvents.Dismissed,
    listenerFunc: () => void,
  ): PluginListenerHandle;
}

interface ListenerB {
  addListener(
    eventName: RewardAdPluginEvents.FailedToLoad,
    listenerFunc: (error: ErrorInfo) => void,
  ): PluginListenerHandle;
  addListener(
    eventName: RewardAdPluginEvents.Rewarded,
    listenerFunc: (item: RewardItem) => void,
  ) : PluginListenerHandle;
}

Validating either ListenerA or ListenerB fails as it should, with a large error message that has a somewhat readable last line:

type Invalid1 = Validate<ListenerA>
// ERROR: ... Type 'RewardAdPluginEvents.FailedToLoad' is not assignable to
//        type 'RewardAdPluginEvents.Dismissed'.
type Invalid2 = Validate<ListenerB> 
// ERROR: ... Type 'RewardAdPluginEvents.Dismissed' is not assignable to
//        type 'RewardAdPluginEvents.FailedToLoad'.

When the two are combine, all overloads are present and, validation passes:

export type Combined = Validate<ListenerA & ListenerB> // No error!

TypeScript playground

Upvotes: 1

Related Questions