typescriptgenericskeyof

Implicit typescript key map use


Is it possible to implicitly state the possible options (based on the key) for the name property in the example below:

type TypeMap = {
  A: 1 | 2 | 3;
  B: 4 | 5 | 6;
};

type InnerType<TKey extends keyof TypeMap> = {
  key: TKey;
  name: TypeMap[TKey];
  // + other properties;
};

type OuterType = {
  x: InnerType; // Should be agnostic at this point
  y: InnerType; // but typescript requires a generic definition
};

const foo: OuterType = {
  x: { key: "A", name: 2 },
  y: { key: "B", name: 4 },
};

The closest I can come up with would be something like the following, (but I would rather not have to change the format of the respective types):

type TypeMap = {
  A: 1 | 2 | 3;
  B: 4 | 5 | 6;
};

type InnerType = {
  data: Partial<{ [key in keyof TypeMap]: TypeMap[key] }>;
  // + other properties
};

type OuterType = {
  x: InnerType;
  y: InnerType;
};

const foo: OuterType = {
  x: { data: { A: 2 } }, // not blocking me from using `{}` or `{ A: 2, B: 4 }` on the same object
  y: { data: { B: 4 } },
};

Another approach would be the following (but does not limit based on the key):

type TypeMap = {
  A: 1 | 2 | 3;
  B: 4 | 5 | 6;
};

type InnerType = {
  key: keyof TypeMap;
  name: TypeMap[keyof TypeMap];
  // + other properties;
};

type OuterType = {
  x: InnerType;
  y: InnerType;
};

const foo: OuterType = {
  x: { key: "A", name: 2 }, // allows me to use `{ key: "A", name 4 }`
  y: { key: "B", name: 4 },
};

Solution

  • Your version of InnerType is a single object type whose properties are unions, which allows mixing of key and name in a way you don't want. You really want InnerType to itself be a union of object types, like this:

    type InnerType = {
        key: "A";
        name: 1 | 2 | 3;
    } | {
        key: "B";
        name: 4 | 5 | 6;
    }
    

    That gives you the behavior you want:

    const foo: OuterType = {
        x: { key: "A", name: 1 },
        y: { key: "B", name: 5 }
    };
    
    const badFoo: OuterType = {
        x: { key: "A", name: 4 }, // error! 
        // Type '{ key: "A"; name: 4; }' is not assignable to type 'InnerType'.
        // Type '4' is not assignable to type '1 | 2 | 3'.
        y: { key: "A", name: 2 }
    }
    

    You can even write InnerType in terms of TypeMap so that it will automatically update if TypeMap is updated:

    type InnerType = { [K in keyof TypeMap]:
        { key: K, name: TypeMap[K] }
    }[keyof TypeMap]
    

    This InnerType is a distributive object type as coined in microsoft/TypeScript#47109. It works by mapping over the keys K of TypeMap, computing the desired object type for each key K, and then indexing into the mapped type with keyof TypeMap, resulting in the desired union.


    If for some reason you want to keep InnerType generic so that InnerType<K> corresponds to a particular K constrained to keyof TypeMap, you can do it:

    type InnerType<K extends keyof TypeMap = keyof TypeMap> =
        { [P in K]:
            { key: P, name: TypeMap[P] }
        }[K]
    
    type InnerTypeA = InnerType<"A">
    /* type InnerTypeA = {
      key: "A";
      name: 1 | 2 | 3;
    } */
    
    type InnerTypeB = InnerType<"B">
    /* type InnerTypeB = {
      key: "B";
      name: 4 | 5 | 6;
    } */
    

    And note that K defaults to keyof TypeMap, so that InnerType without a generic type argument is equivalent to the full union:

    type InnerTypeBoth = InnerType
    /* type InnerTypeBoth = {
      key: "A";
      name: 1 | 2 | 3;
    } | {
      key: "B";
      name: 4 | 5 | 6;
    } */
    

    So you can get both generic-like and union-like behavior depending on your needs.

    Playground link to code