TypeScript: Double inferring by if statements

72 views Asked by At

Currently I infer type of one property by other property value, but I hit the wall, when I've tried to infer type of one property by second property which is narrowed by third property.

I have a types which describes Game Events, each Game Event is described by 3 properties like so:

  • evt - type of game
  • evn - name of event
  • evd - data associated with event
(() => {
    const e: AnyEvent = {} as any;

    if (e.evt === EventType.CHESS && e.evn == "make_move" ) {
        e.evd; // here we want the e.evd type to be correctly inferred as: EventData & {from: string; to: string;}

        //as for now, e.evd is inferred as all possible CHESS event datas:
        /*
            evd: (EventData & {
                from: string;
                to: string;
            }) | (EventData & {
                count: number;
            })
        */
    }

    if (e.evt === EventType.CHESS && e.evn == "goback" ) {
        e.evd; // here we want the e.evd type to be correctly inferred as: EventData & {count: number;}

        //as for now, e.evd is inferred as all possible CHESS event datas:
        /*
            evd: (EventData & {
                from: string;
                to: string;
            }) | (EventData & {
                count: number;
            })
        */
    }

    if (e.evt === EventType.QUIZ && e.evn == "answer" ) {
        e.evd; // here we want the e.evd type to be correctly inferred as: EventData & {body: string;}

        //as for now, e.evd is inferred as all possible QUIZ event datas:
        /*
            evd: (EventData & {
                body: string;
            }) | (EventData & {
                rate: number;
            })
        */
    }
})();

whole code is here: Typescript Playground Link

and also below here:

interface ChessEventList {
    make_move: EventData & {
        from: string;
        to: string;
    };

    goback: EventData & {
        count: number;
    };
}

interface QuizEventList {
    answer: EventData & {
        body: string;
    };

    rate_current: EventData & {
        rate: number;
    };
}

//

enum EventType {
    CHESS,
    QUIZ,
}

interface EventListByType {
    [EventType.CHESS]: ChessEventList;
    [EventType.QUIZ]: QuizEventList;
}

//

interface EventData {
    gameId: string;
}


interface SomeSendOptions {
    timeout?: number;
}

function send<
    EVT extends keyof EventListByType,
    EVN extends keyof EventListByType[EVT],
    EVD extends EventListByType[EVT][EVN]
>(
    evt: EVT,
    evn: EVN,
    evd: EVD,
    options?: SomeSendOptions
) {
    //[...]
}

send(EventType.CHESS, "goback", { gameId: "1", count: 5 });
send(EventType.QUIZ, "answer", { gameId: "2", body: "my answer is..." }, { timeout: 1000 });
send(EventType.QUIZ, "make_move", { gameId: "3", from: "2D", to: "4E" }, { timeout: 1000 }); //not allowed, should lead to error because "make_move" is CHESS event.

//

type AnyEvent<EVENT_TYPE extends EventType = EventType> = {
    [EVT in EVENT_TYPE]: {
        evt: EVT;
        evn: keyof EventListByType[EVT];
        evd: EventListByType[EVT][keyof EventListByType[EVT]];
    }
}[EVENT_TYPE];

//

(() => {
    const e: AnyEvent = {} as any;

    if (e.evt === EventType.CHESS && e.evn == "make_move" ) {
        e.evd; // here we want the e.evd type to be correctly inferred as: EventData & {from: string; to: string;}

        //as for now, e.evd is inferred as all possible CHESS event datas:
        /*
            evd: (EventData & {
                from: string;
                to: string;
            }) | (EventData & {
                count: number;
            })
        */
    }

    if (e.evt === EventType.CHESS && e.evn == "goback" ) {
        e.evd; // here we want the e.evd type to be correctly inferred as: EventData & {count: number;}

        //as for now, e.evd is inferred as all possible CHESS event datas:
        /*
            evd: (EventData & {
                from: string;
                to: string;
            }) | (EventData & {
                count: number;
            })
        */
    }

    if (e.evt === EventType.QUIZ && e.evn == "answer" ) {
        e.evd; // here we want the e.evd type to be correctly inferred as: EventData & {body: string;}

        //as for now, e.evd is inferred as all possible CHESS event datas:
        /*
            evd: (EventData & {
                body: string;
            }) | (EventData & {
                rate: number;
            })
        */
    }
})();

I've come up with idea that works well, but I can't tie it together with EVENT_NAME generic type, which is required because AnyEvent type should has option to narrowing event kind also in declaration, with EVENT_TYPE it's easy, but can't make use of EVENT_NAME:

type AnyEvent<EVENT_TYPE extends EventType = EventType, EVENT_NAME extends keyof EventListByType[EVENT_TYPE] = keyof EventListByType[EVENT_TYPE]> = {
    [EVT in EVENT_TYPE]: {
        [EVN in keyof EventListByType[EVT]]: {
            evt: EVT,
            evn: EVN,
            evd: EventListByType[EVT][EVN]
        }
    }[keyof EventListByType[EVT]];
}[EVENT_TYPE];

Here goes final playground with code above implemented and test cases:

Final Typescript Playground

I put test cases also here:

(() => {
    const e: AnyEvent = {} as any;

    if (e.evt === EventType.CHESS && e.evn == "make_move" ) {
        e.evd; // here the e.evd type is correctly inferred as: EventData & {from: string; to: string;}
    }

    if (e.evt === EventType.CHESS && e.evn == "goback" ) {
        e.evd; // here the e.evd type is correctly inferred as: EventData & {count: number;}
    }

    if (e.evt === EventType.QUIZ && e.evn == "answer" ) {
        e.evd; // here the e.evd type is correctly inferred as: EventData & {body: string;}
    }

    // the problem is below:

    const e2: AnyEvent<EventType.QUIZ> = {
        evt:    EventType.QUIZ,
        evn:    "rate_current", //everything is fine, we haven't narrowed it in line: `const e2: AnyEvent<EventType.QUIZ>`. it can be "answer" or "rate_current"
        evd:    {gameId: "1", rate: 10}
    };

    const e3: AnyEvent<EventType.QUIZ, "answer"> = {
        evt:    EventType.QUIZ,
        evn:    "rate_current", //should lead to error, because we narrowed it in line: `const e3: AnyEvent<EventType.QUIZ, "answer">` to be only "answer"
        evd:    {gameId: "1", rate: 10}
    };
})();
2

There are 2 answers

0
Sonaht On BEST ANSWER

Looks like I've found solution:

type AnyEvent<EVENT_TYPE extends EventType = EventType, EVENT_NAME extends (keyof EventListByType[EVENT_TYPE] | never) = never> = {
    [EVT in EVENT_TYPE]: {
        [EVN in ([EVENT_NAME] extends [never] ? keyof EventListByType[EVT] : EVENT_NAME)]: {
            evt: EVT,
            evn: EVN,
            evd: EventListByType[EVT][EVN]
        }
    }[([EVENT_NAME] extends [never] ? keyof EventListByType[EVT] : EVENT_NAME)];
}[EVENT_TYPE];

Here in code above I used never as a placeholding value, and then inside of [EVT in EVENT_TYPE]: { reveal never as desired EventListByType but with iterating product EVT like so: EventListByType[EVT] - of course only then when it's never otherwise use provided value.

3
cefn On

It looks like you're trying to build a typing system within a typing system and I don't really know why.

The fundamentals of the call signatures you want can be achieved as Tuples as below, without building a type system.

However, it's much more conventional to use discriminated unions that are objects and not have to reason about spreading them over an arg list, which seems unnecessary to me...

interface SomeSendOptions {
  timeout?: number;
}

type MakeMoveTuple = [
  "chess",
  "makemove",
  {
    from: string;
    to: string;
  },
  SomeSendOptions  
];

type GoBackTuple = [
  "chess",
  "goback",
  {
    count:number
  },
    SomeSendOptions  
];

type GameTuple = MakeMoveTuple | GoBackTuple

function send<T extends GameTuple>(...tuple:T) {
    const [typeName, actionName, actionOptions, sendOptions] = tuple;
    if(typeName==="chess"){
        if(actionName==="makemove"){
            const { from, to } = actionOptions
        }
        if(actionName==="goback"){
            const { count } = actionOptions
        }
    }
}

send("chess", "makemove", { "from": "something", "to": "something"}, {timeout:2000})
send("chess", "goback", { "count": 3}, {timeout:2000})