javascripttypescriptindex-signature

How to match value types to keys when using index signatures in TypeScript?


I have a code in a library for doing some kind of eventsourcing. It defines events with a given type and data for each kind of events.

I want to write a reduce function that walks through a list of events and apply them to a given initial value.

My question is how I can define the type of the reducer function eventData parameter to match the current key? My goal is to get TypeScript to infer parameter types based on the object key so I don't have to typecast the event data parameters (i.e.: (eventData as AddEvent['data'])).

In a nutshell: currently the wrongReducer object leads to TypeScript errors but I want to define the Reducer type to make it work this way.

Is there any TypeScript magic to achieve this?

// Library code:

export interface Event<
    Type extends string = string,
    Data extends Record<string, unknown> = Record<string, unknown>
> {
    type: Type
    data: Data
}

type Reducer<T, E extends Event> = {
    [k in E['type']]: (current: T, eventData: E['data']) => T
    // What to write instead of E['data']?
}

// Example code:

type AddEvent = Event<'add', { addend: number }>
type SubstractEvent = Event<'substract', { subtrahend: number }>
type MultiplyEvent = Event<'multiply', { multiplicant: number }>

type OperatorEvent = AddEvent | SubstractEvent | MultiplyEvent

const reducer: Reducer<number, OperatorEvent> = {
    add: (current, eventData) => current + (eventData as AddEvent['data']).addend,
    substract: (current, eventData) => current - (eventData as SubstractEvent['data']).subtrahend,
    multiply: (current, eventData) => current * (eventData as MultiplyEvent['data']).multiplicant
}

/*
const wrongReducer: Reducer<number, OperatorEvent> = {
    add: (current, eventData) => current + eventData.addend,
    substract: (current, eventData) => current - eventData.subtrahend,
    multiply: (current, eventData) => current * eventData.multiplicant
}
// TSError: ⨯ Unable to compile TypeScript:
// test/so.ts:32:54 - error TS2339: Property 'addend' does not exist on type '{ addend: number; } | { subtrahend: number; } | { multiplicant: number; }'.
//   Property 'addend' does not exist on type '{ subtrahend: number; }'.

// 32     add: (current, eventData) => current + eventData.addend,
//                                                         ~~~~~~
// test/so.ts:33:60 - error TS2339: Property 'subtrahend' does not exist on type '{ addend: number; } | { subtrahend: number; } | { multiplicant: number; }'.
//   Property 'subtrahend' does not exist on type '{ addend: number; }'.

// 33     substract: (current, eventData) => current - eventData.subtrahend,
//                                                               ~~~~~~~~~~
// test/so.ts:34:59 - error TS2339: Property 'multiplicant' does not exist on type '{ addend: number; } | { subtrahend: number; } | { multiplicant: number; }'.
//   Property 'multiplicant' does not exist on type '{ addend: number; }'.

// 34     multiply: (current, eventData) => current * eventData.multiplicant
//                                                                         ~~~~~~~~~~~~
*/

const events: OperatorEvent[] = [
    { type: 'add', data: { addend: 2 } },
    { type: 'multiply', data: { multiplicant: 5 } },
    { type: 'add', data: { addend: 3 } },
    { type: 'substract', data: { subtrahend: 4 } }
]

let val = 0

for (const event of events) {
    val = reducer[event.type](val, event.data)
}

console.log(`Result: ${val}`)
// Result: 9



Solution

  • I got this to work using a bit of a creative version of key remapping:

    type Reducer<T, E extends Event> = {
        [event in E as event['type']]: (current: T, eventData: event['data']) => T;
    }
    

    Basically event refers to whatever E can be, which for Reducer<any, OperatorEvent> is AddEvent, SubstractEvent or MultiplyEvent.

    The eventData parameter in your reducer/wrongReducer's fields are properly typed based on the field name. E.g. eventData for add is of type:

    enter image description here


    In a more classic fashion, OperatorEvent would usually be replaced with an "event map" or similar, which is a lot easier and more straight-forward to map types over.