Home > Mobile >  Narrowing member type based on another member
Narrowing member type based on another member

Time:01-01

type Event<S extends string> = {
    name: S
}

const EventHandler = <S extends string, T extends Event<S>[]>(arr: [...T]) => {
    let lastEvent: T[number] = arr[0]
    return {
        set: (event: T[number]) => lastEvent = event,
        emit: (event: typeof lastEvent['name']) => { },
        lastEvent
    }
}

const emitter = EventHandler([{ name: 'A' }, { name: 'B' }])

if(emitter.lastEvent.name === 'A') {
    // `emitter.lastEvent` is now inferred to `{ name: A }`
    
    emitter.emit('B') // compiles, but I want it to only allow the string `A`
}

In this contrived example, I create some object EventHandler that has some configuration lastEvent. Now, I want the parameter of emit to be narrowed down depending on the configuration.

However, even though the type of lastEvent gets narrowed down in the conditional, I'm unable to narrow down the input of emit as a result of that.

Is this possible? Preferably without using user defined type guards...

[EDIT]:

I thought I could make a compromise and add the event object as input to emit:

emit: (event: T[number], name: typeof event['name']) => { }

and then do:

emitter.emit({ name: 'A' }, 'B') // why does this compile ?!

but that also doesn't get inferred! This seems strange - why can't typescript handle this?

CodePudding user response:

To solve this, we would ideally build a discriminated union so that the type of the emit function can be narrowed by checking the type of lastEvent. This would require us to check the nested value of emitter.lastEvent.name. But that's a problem since TypeScript currently does not allow narrowing discrimanted unions based on deeply nested properties (see #18758).

What we can do for now is to avoid the nesting and have the string value of name on the top-level of the object.

We can use the union of T[number] with a mapped type to build a discriminated union where each union constituent K is the discriminant.

const EventHandler = <T extends string[]>(arr: [...T]) => {
    let lastEvent: T[number] = arr[0]
    return {
        set: (event: T[number]) => lastEvent = event,
        emit: (event: typeof lastEvent) => { },
        lastEvent
    } as {
        set: (event: T[number]) => void 
    } & {
        [K in T[number]]: {
            emit: (event: K) => void
            lastEvent: K
        }
    }[T[number]]
}

This lets us properly narrow the type of emit by checking the value of lastEvent.

const emitter = EventHandler(['A', 'B'])

if(emitter.lastEvent === 'A') {    
    emitter.emit('A')
    

    emitter.emit('B') // Error: Argument of type '"B"' is not assignable 
                      // to parameter of type '"A"'
}

if(emitter.lastEvent === 'B') {
    emitter.emit('A') // Error: Argument of type '"A"' is not assignable 
                      // to parameter of type '"B"'
    
    emitter.emit('B')
}

emitter.set('A')
emitter.set('B')

Playground

  • Related