3

The problem

Suppose I have some code like this:

// Events we might receive:
enum EventType { PlaySong, SeekTo, StopSong };

// Callbacks we would handle them with:
type PlaySongCallback = (name: string) => void;
type SeekToCallback = (seconds: number) => void;
type StopSongCallback = () => void;

In the API I'm given, I can register such a callback with

declare function registerCallback(t: EventType, f: (...args: any[]) => void);

But I want to get rid of that any[] and make sure I can't register an ill-typed callback function.

A solution?

I realized I can do this:

type CallbackFor<T extends EventType> =
    T extends EventType.PlaySong
        ? PlaySongCallback
        : T extends EventType.SeekTo
            ? SeekToCallback
            : T extends EventType.StopSong
                ? StopSongCallback
                : never;

declare function registerCallback<T extends EventType>(t: T, f: CallbackFor<T>);

// Rendering this valid:
registerCallback(EventType.PlaySong, (name: string) => { /* ... */ })

// But these invalid:
// registerCallback(EventType.PlaySong, (x: boolean) => { /* ... */ })
// registerCallback(EventType.SeekTo, (name: string) => { /* ... */ })

This is really nifty and powerful! It feels like I'm using dependent types: I basically wrote myself a function mapping values to types, here.

However, I don't know the full strength of TypeScript's type system and maybe there is an even better way to map enum values to types like this.

The question

Is there a better way to map enum values to types like this? Can I avoid a really big conditional type as above? (In reality I have many events, and it's kind of a mess: VS Code shows a huge expression when I hover over CallbackFor, and my linter really wants to indent after every :.)

I'd love to write an object mapping enum values to types, so I can declare registerCallback using T and CallbackFor[T], but that doesn't seem to be a thing. Any insights are appreciated!

Lynn
  • 10,425
  • 43
  • 75

2 Answers2

6

We can create a type that maps between the enum members and the callback types, but the if we use it directly in registerCallback we will not get correct inference for callback argument types:

type EventTypeCallbackMap = {
    [EventType.PlaySong] : PlaySongCallback,
    [EventType.SeekTo] : SeekToCallback,
    [EventType.StopSong] : StopSongCallback,
}

declare function registerCallback
    <T extends EventType>(t: T, f: EventTypeCallbackMap[T]): void;

registerCallback(EventType.PlaySong, n => { }) // n is any

If you have just 3 event types, multiple overloads are actually a pretty good solution:

declare function registerCallback(t: EventType.PlaySong, f: PlaySongCallback): void;
declare function registerCallback(t: EventType.SeekTo, f: SeekToCallback): void;
declare function registerCallback(t: EventType.StopSong, f: StopSongCallback): void;

registerCallback(EventType.PlaySong, n => { }) // n is string

If you have a lot of enum member you could also generate the overload signature automatically:

type EventTypeCallbackMap = {
    [EventType.PlaySong]: PlaySongCallback,
    [EventType.SeekTo]: SeekToCallback,
    [EventType.StopSong]: StopSongCallback,
}

type UnionToIntersection<U> = 
(U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never
declare let registerCallback: UnionToIntersection<
    EventType extends infer T ?
    T extends T ? (t: T, f: EventTypeCallbackMap[T]) => void :
    never: never
> 


registerCallback(EventType.PlaySong, n => { }) // n is string

See here (and up-vote the answer) for an explanation of UnionToIntersection

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • Nice answer! That `UnionToIntersection` type looks pretty magical. Could you explain what it achieves here? – Lynn Nov 06 '18 at 14:09
  • Also is the `U extends any ? (k: U) => void : never` really necessary? Why can't that just be `(k: U) => void`? – Patrick Roberts Nov 06 '18 at 14:11
  • @Lynn added link to unionto intersection it belongs to jcalz – Titian Cernicova-Dragomir Nov 06 '18 at 14:14
  • 1
    @PatrickRoberts That uses the distributive property of conditional types, they distribute over naked type parameters, that is why the convoluted `EventType extends infer T ? T extends any ?` we introduce a type parameter and distribute over it. Same for the `U` in `UnionToIntersection` – Titian Cernicova-Dragomir Nov 06 '18 at 14:15
  • @PatrickRoberts you can read more about that https://www.typescriptlang.org/docs/handbook/advanced-types.html if you search for Distributive conditional types – Titian Cernicova-Dragomir Nov 06 '18 at 14:16
  • Thanks! Incidentally, do you have any idea why the inference of `n` fails? Curiously, when I try your first example in the [playground](https://www.typescriptlang.org/play/) and hover over the call to `registerCallback` I do see _function registerCallback(t: EventType.PlaySong, f: PlaySongCallback)_ in the tooltip. So it seems to be inferring the type of `f` correctly, at least, and then I don't see why the inference of `n` would fail. – Lynn Nov 06 '18 at 14:23
  • 1
    @Lynn if you enable noImplictAny you will get an error on `n` as it is typed as `any` not `string` as you would expect. Typescript generally fails to do this, infer callback arguments if the callback type itself is dependent on another type parameter. The last approach works because we basically pre-generate all possible signatures simulating multiple overloads, and there the type of the callback does not depend on the type of the first argument but rather on the selected overload (which does depend on the first argument... yes typescript is weird sometimes :) ) – Titian Cernicova-Dragomir Nov 06 '18 at 14:25
  • (In fact, passing `(n: number) => { }` gets me an error!) – Lynn Nov 06 '18 at 14:25
  • 1
    @Lynn yes specifying the wrong arugment will get an error, but not specifying anything will get you `n:any` which means you can do `registerCallback(EventType.PlaySong, n => { n.ddd }) //` – Titian Cernicova-Dragomir Nov 06 '18 at 14:27
  • 1
    First, this is amazing. Thank you. Second, in more recent versions of Typescript, I had to change `T extends any ?` to `T extends EventType ?`. Otherwise, you get an error with `EventTypeCallbackMap[T]`: "Type 'T' cannot be used to index type 'EventTypeCallbackMap'.(2536)" – Taytay May 28 '20 at 04:34
2

Instead of setting up a complicated mapping, consider using override declarations:

declare function registerCallback(t: EventType.PlaySong, f: PlaySongCallback);
declare function registerCallback(t: EventType.SeekTo, f: SeekToCallback);
declare function registerCallback(t: EventType.StopSong, f: StopSongCallback);

I find this much more readable and maintainable than setting up an explicit mapping type, though I understand the inconvenience of not having a single generic signature. One thing you have to remember is that people using your API will definitely prefer the transparency of override declarations to the opaque type CallbackFor<T> which isn't really self-explanatory.

Try it out on TypeScript Playground, and don't forget to provide the return type for registerCallback() if you have the noImplicitAny flag set.

Patrick Roberts
  • 49,224
  • 10
  • 102
  • 153