shennan
shennan

Reputation: 11666

Mapping a variable number of generics while retaining link between type values

I have a factory function which creates a classic on() event listener, but one that is specific to whatever even types I want to allow the user to listen to. The events are defined as types and they have eventName and data (which is the data that is returned when the event emits). I would like to keep these two related to each-other, such that if I were to listen for a specific event then the relevant data will be available to me in the handler.

Consider this code:

interface DefaultEventBody {
  eventName: string
  data: unknown
}

interface ChristmasEvent extends DefaultEventBody {
  eventName: 'christmas',
  data: {
    numberOfPresentsGiven: number
  }
}

interface EasterEvent extends DefaultEventBody {
  eventName: 'easter',
  data: {
    numberOfEggsGiven: number
  }
}

export function Listener <EventBody extends DefaultEventBody> () {

  return (eventName: EventBody['eventName'], fn: (data: EventBody['data']) => void) => {
    // someEmitter.on(eventName, fn)
  }
}

const spoiltBrat = { on: Listener<EasterEvent|ChristmasEvent>() }

spoiltBrat.on('christmas', (data) => {

  console.log(data.numberOfPresentsGiven)

})

TypeScript rightly knows that the eventName I pass can be christmas|easter, but it is unable to infer the data type on the handler, and subsequently errors when I attempt to access data.numberOfPresentsGiven.

Property 'numberOfPresentsGiven' does not exist on type '{ numberOfPresentsGiven: number; } | { numberOfEggsGiven: number; }'. Property 'numberOfPresentsGiven' does not exist on type '{ numberOfEggsGiven: number; }'.(2339)

I'm aware of why this is happening (because neither of the types ChristmasEvent and EasterEvent contain the same numberOf* properties), but wondered if there was a solution to what I want to achieve?

Update

As per the request from Captain Yossarian, here is a Playground Link with the almost finished script.

Upvotes: 1

Views: 167

Answers (1)

Quick fix

Please see this answer:

type UnionKeys<T> = T extends T ? keyof T : never;
type StrictUnionHelper<T, TAll> =
  T extends any
  ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;

// credits goes to https://stackoverflow.com/questions/65805600/type-union-not-checking-for-excess-properties#answer-65805753
type StrictUnion<T> = StrictUnionHelper<T, T>

export function Listener<EventBody extends DefaultEventBody>() {

  return (eventName: EventBody['eventName'], fn: (data: StrictUnion<EventBody['data']> /** <---- change is here */) => void) => {
    // someEmitter.on(eventName, fn)
  }
}


const spoiltBrat = {
  on: Listener<EasterEvent | ChristmasEvent>()
}

spoiltBrat.on('christmas', (data) => {
  data.numberOfPresentsGiven // <---- DRAWBACK, number | undefined
})

Above solution works but it has its own drawbacks. As you might have noticed, numberOfPresentsGiven is allowed but it might be undefined. This is not what we want.

Longer fix

Usually, if you want to type publish/subscribe logic, you should go with overloadings. Consider this example:

type AllowedEvents = ChristmasEvent | EasterEvent

type Events = AllowedEvents['eventName']

// type Overloadings = {
//     christmas: (eventName: "christmas", fn: (data: ChristmasEvent) => void) => void;
//     easter: (eventName: "easter", fn: (data: EasterEvent) => void) => void;
// }
type Overloadings = {
  [Prop in Events]: (eventName: Prop, fn: (data: Extract<AllowedEvents, { eventName: Prop }>) => void) => void
}

Now we have a data structure with appropriate types of our on function. On order to apply this DS to on and make it act as overloadings, we need to obtain a union type of Overloadings props and merge them (intersection). Why intersection ? Because intersection of function types produces overlodings.

Let's obtain a union of values:

type Values<T>=T[keyof T]

type Union = 
| ((eventName: "christmas", fn: (data: ChristmasEvent) => void) => void) 
| ((eventName: "easter", fn: (data: EasterEvent) => void) => void)
type Union = Values<Overloadings>

Now, when we have a union, we can convert it to intersection with help of utility type:

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;
  
type EventsOverload = UnionToIntersection<Union>

Temporary solution:


type AllowedEvents = ChristmasEvent | EasterEvent

type Events = AllowedEvents['eventName']


type Overloadings = {
  [Prop in Events]: (eventName: Prop, fn: (data: Extract<AllowedEvents, { eventName: Prop }>['data']) => void) => void
}

type Values<T> = T[keyof T]

type Union = Values<Overloadings>


// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type EventsOverload = UnionToIntersection<Union> & ((eventName: string, fn: (data: any) => void) => void)


export function Listener(): EventsOverload {

  return (eventName, fn: (data: any) => void) => {
    // someEmitter.on(eventName, fn)
  }
}


const spoiltBrat = {
  on: Listener()
}

spoiltBrat.on('christmas', (data) => {

  data.numberOfPresentsGiven // number
})

spoiltBrat.on('easter', (data) => {
  data.numberOfEggsGiven // number
})

However, it is not perfect yet. You propbably have noticed that I have used any. Nobody likes any. Instead of any, you can provide an intersection of all allowed data arguments:

export function Listener(): EventsOverload {
  return (eventName, fn: (data: ChristmasEvent['data'] & EasterEvent['data']) => void) => {
  }
}

Why intersection ? Because this is the only safe way to handle any eventName. Here you can find more context and explanation.

Whole solution:

interface DefaultEventBody {
  eventName: string
  data: unknown
}

interface ChristmasEvent extends DefaultEventBody {
  eventName: 'christmas',
  data: {
    numberOfPresentsGiven: number
  }
}

interface EasterEvent extends DefaultEventBody {
  eventName: 'easter',
  data: {
    numberOfEggsGiven: number
  }
}

type AllowedEvents = ChristmasEvent | EasterEvent

type Events = AllowedEvents['eventName']

type Overloadings = {
  [Prop in Events]: (eventName: Prop, fn: (data: Extract<AllowedEvents, { eventName: Prop }>['data']) => void) => void
}

type Values<T> = T[keyof T]

type Union = Values<Overloadings>

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type EventsOverload = UnionToIntersection<Union>

export function Listener(): EventsOverload {
  return (eventName, fn: (data: UnionToIntersection<AllowedEvents['data']>) => void) => { }
}


const spoiltBrat = {
  on: Listener()
}

spoiltBrat.on('christmas', (data) => {

  data.numberOfPresentsGiven // number
})

spoiltBrat.on('easter', (data) => {
  data.numberOfEggsGiven // number
})

Playground

Here you have another example, taken from my blog:



const enum Events {
  foo = "foo",
  bar = "bar",
  baz = "baz",
}

/**
 * Single sourse of true
 */
interface EventMap {
  [Events.foo]: { foo: number };
  [Events.bar]: { bar: string };
  [Events.baz]: { baz: string[] };
}



type EmitRecord = {
  [P in keyof EventMap]: (name: P, data: EventMap[P]) => void;
};

type ListenRecord = {
  [P in keyof EventMap]: (
    name: P,
    callback: (arg: EventMap[P]) => void
  ) => void;
};

type Values<T> = T[keyof T];

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;
  
type MakeOverloadings<T> = UnionToIntersection<Values<T>>;

type Emit = MakeOverloadings<EmitRecord>;
type Listen = MakeOverloadings<ListenRecord>;

const emit: Emit = <T,>(name: string, data: T) => { };

emit(Events.bar, { bar: "1" });
emit(Events.baz, { baz: ["1"] });
emit("unimplemented", { foo: 2 }); // expected error

const listen: Listen = (name: string, callback: (arg: any) => void) => { };

listen(Events.baz, (arg /* {baz: string[] } */) => { });
listen(Events.bar, (arg /* {bar: string } */) => { });

Playground

Please keep in mind that your emitter and listener should have single sourse of true. I mean they shouls use shared event map.


UPDATE

It is a good practice to define your types in global scope. You almost never need to declare types inside function.

/*
 * ListenerFactory.ts
 */

interface DefaultEventBody {
  eventName: string
  data: unknown
}
type Values<T> = T[keyof T]
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type Overloadings<E extends DefaultEventBody> = {
  [Prop in E['eventName']]: (eventName: Prop, fn: (data: Extract<E, { eventName: Prop }>['data']) => void) => void
}

export function Listener<AllowedEvents extends DefaultEventBody>(): UnionToIntersection<Values<Overloadings<AllowedEvents>>>
export function Listener<AllowedEvents extends DefaultEventBody>() {
  return (eventName: string, fn: (data: UnionToIntersection<AllowedEvents['data']>) => void) => { }
}

/*
 * ConsumingLibrary.ts
 */

interface ChristmasEvent extends DefaultEventBody {
  eventName: 'christmas',
  data: {
    numberOfPresentsGiven: number
  }
}

interface EasterEvent extends DefaultEventBody {
  eventName: 'easter',
  data: {
    numberOfEggsGiven: number
  }
}

const spoiltBrat = {
  on: Listener<ChristmasEvent | EasterEvent>()
}

spoiltBrat.on('christmas', (data) => {

  data.numberOfPresentsGiven // number
})

spoiltBrat.on('easter', (data) => {
  data.numberOfEggsGiven // number
})

Playground

Upvotes: 1

Related Questions