1

Say I've got these types defined:

export type Event = {
  name: EventName,
  timestamp: Timestamp,
};

export type EventWithMetadata = Event & {
  deviceId: ID,
};

I'm looking to define a common function interface like so:

export type SideEffect = (event: Event, run?: Run) => Partial<Run>;

and using it with this function

// Note: many of these side-effect functions exist, with multiple specializations of the base Event
// type as potential arguments. This is just one example.
const updateTiming: SideEffect = (event: EventWithMetadata, run) => {
  log.debug('updateTiming: %j', event);
  return {
    lastUpdated: event.timestamp,
    deviceId: event.deviceId,
  };
};

but then I hit this error:

error TS2322: Type '(event: EventWithMetadata, run: Run | undefined) => { startTime: number; deviceId: string; }' is not assignable to type 'SideEffect'.
  Types of parameters 'event' and 'event' are incompatible.
    Type 'Event' is not assignable to type 'EventWithMetadata'.
      Property 'deviceId' is missing in type 'Event' but required in type '{ deviceId: string; }'.

which makes sense: Typescript thinks I'm trying to downcast Event into EventWithMetadata, which obviously can't happen.

After a lot of searching on generics and overloads and union types, the best answer I could find is this one. I really hate the result and wouldn't want to make it a pattern:

const updateTiming: SideEffect = (inputEvent: Event, run) => {
  const event = inputEvent as EventWithMetadata;   // <- This sucks.
  log.debug('updateTiming: %j', event);
  return {
    lastUpdated: event.timestamp,
    deviceId: event.deviceId,
  };
};

Are there better solutions than casting?

Nick Sweet
  • 2,030
  • 3
  • 31
  • 48
  • The error is trying to warn you about potential unsoundness. Let's say someone wants to call the function of type `SideEffect`. The signature suggests that passing an argument of type `Event` is fine. But your implementation actually wants `EventWithMetadata`. Accessing `event.deviceId` would lead to `undefined` even though it is typed as `ID`. – Tobias S. Dec 28 '22 at 18:39
  • To clarify -- `SideEffect` is a function that triggers based on input `Event`s, which have different shapes. I'm trying to say that any `SideEffect` function must at least handle the baseline `Event` arguments, but each `SideEffect` function will handle a special `EventWithMetadata` or `EventWithUserInformation` or `EventWithKetchupAndRelish` event. I have an object that owns a collection of `SideEffect` functions, which it will call via a mapping of `Event` type names. – Nick Sweet Dec 28 '22 at 18:54
  • A [mre] would be nice here so we don't have to fake up whatever `Run`, etc, are in order to test. – jcalz Dec 28 '22 at 19:01
  • 1
    Sounds like the typing of the mapping would lead to the real solution of your problem. You should probably include it in the question. – Tobias S. Dec 28 '22 at 19:01
  • With the information provided here I'd say you want `SideEffect` to be *generic*, like [this playground link](https://tsplay.dev/N728Pw) shows. If that meets your needs I could write up an answer explaining. If not, what specifically goes wrong with it? (Please mention @jcalz to notify me if you reply) – jcalz Dec 28 '22 at 19:04
  • @jcalz you got it! This was the code I'd originally tried with generics: ```export type SideEffect = (event: Type, run?: Run) => Partial;``` This is the correct code: ```export type SideEffect = (event: Type, run?: Run) => Partial; ``` Thanks for the help! – Nick Sweet Dec 28 '22 at 19:18
  • Though, adding for others that it looks like defining an array of these `SideEffect` functions leads to the need for [existential generic types](https://stackoverflow.com/a/65129942/362703) – Nick Sweet Dec 28 '22 at 19:21
  • Okay I'll write up an answer, although if you have an array of these things you need some way to distinguish which events to dispatch to which ones, which might *not* require existential generics. But that's out of scope for the question as asked, so I won't belabor the point. – jcalz Dec 28 '22 at 19:23

1 Answers1

2

If you need updateTiming to handle EventWithMetadata specifically, then the type of updateTiming needs to know about EventWithMetadata. In order for SideEffect to know about EventWithMetadata and still work with other Event types, you probably want it to be generic in the particular subtype of Event that it handles:

type SideEffect<T extends Event> =
    (event: T, run?: Run) => Partial<Run>;

Then updateTiming is a SideEffect<EventWithMetadata> and not just a SideEffect:

const updateTiming: SideEffect<EventWithMetadata> = (event, run) => {
    console.debug('updateTiming: %j', event);
    return {
        lastUpdated: event.timestamp,
        deviceId: event.deviceId,
    };
}; // okay

Note that it's not possible to hold onto a SideEffect without knowing what it handles, but that's a good thing, because otherwise you could actually pass it the wrong Event. If you think you need a SideEffect[] without a type argument, then you will have to refactor so that you can distinguish events and event handlers somehow via a test (ideally both handlers and events would be a discriminated union but otherwise you could write custom type guard functions). But that's out of scope for this question as asked.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360