typescripttypesenumstypemaps

How to use type maps inside nested objects in Typescript?


Firstly, a type is created that associates enum keys with types:

enum MyEnum {
    A,
    B
}

type TypeMap = {
    [MyEnum.A]:string,
    [MyEnum.B]:number
}

interface ObjInterface<T extends keyof TypeMap> {
    obj: T,
    objData: TypeMap[T]
}

interface SecondaryInterface {
    value: string,
    objChosen: ObjInterface<keyof TypeMap>
}

Then an object is made where objData's type gets verified against the TypeMap:

myObj:SecondaryInterface = {value:"", objChosen:{obj:MyEnum.A, objData:"a string"}}

This partly works but when I type objData, it gives a union type hint 'string | number' and not just 'string' because it infers the type from keyof TypeMap rather than the exact TypeMap[T].

Is it possible to get an exact type match for the enum key used and its associated type set in the type map?

It can be made to work using a type assertion but can this be made to work without one?:

myObj:SecondaryInterface = {value:"", objChosen:<ObjInterface<MyEnum.A>>{obj:MyEnum.A, objData:"a string"}}

Solution

  • According to your definition, ObjInterface<MyEnum.A | MyEnum.B> is a single object type where obj can be any MyEnum and objData can be string | number. But that's no what you want. You'd like ObjInterface<T> to distribute over unions in T, so that ObjInterface<MyEnum.A | MyEnum.B> is evaluated as ObjInterface<MyEnum.A> | ObjInterface<MyEnum.B>. There are different ways to accomplish that, such as using distributive conditional types, but when the thing you want to distribute over is keylike, I usually prefer to write a distributive object type as coined in microsoft/TypeScript#47109. That's where you index into a mapped type.

    If you have a keylike type K, and you want to distribute the type function F<K> over unions in K, then you can write {[P in K]: F<P>}[K]. That turns into a mapped type with one property for each member of K, which is immediately indexed into with K to make a union of those properties.

    For your code that looks like:

    type ObjInterface<K extends keyof TypeMap> = {
      [P in K]: {
        obj: P,
        objData: TypeMap[P]
      }
    }[K]
    

    And then ObjInterface<keyof TypeMap> evaluates to

    /* type ObjInterface<keyof TypeMap> = {
        obj: MyEnum.A;
        objData: string;
    } | {
        obj: MyEnum.B;
        objData: number;
    } */
    

    This isn't strictly an interface anymore, so maybe the name should be changed.


    Of course at this point you haven't actually shown a reason why you need ObjInterface to continue to be generic, if all you care about is plugging in keyof TypeMap there. If you don't need that the generic, you can hardcode K to be keyof TypeMap and make it

    type ObjInterface = { [K in keyof TypeMap]: {
      obj: K,
      objData: TypeMap[K]
    } }[keyof TypeMap]
    

    And then the rest of your code is

    interface SecondaryInterface {
      value: string,
      objChosen: ObjInterface
    }
    
    const myObj: SecondaryInterface =
      { value: "", objChosen: { obj: MyEnum.A, objData: "a string" } }; // okay
    myObj.objChosen = { obj: MyEnum.B, objData: 123 }; // okay
    myObj.objChosen = { obj: MyEnum.A, objData: 123 }; // error
    

    as desired.

    Playground link to code