Self reference key type in a TypeScript interface

Viewed 1445

I have an object with the following interface that I would like to update programatically:

interface ITypes {
  num: number;
  str: string;
  bol: boolean;
};

const obj: Partial<ITypes> = {}; // I want to update this programatically

I want to be able to define a key and a val and update my obj using those values. I want it so that the key can only be one of the keys from the ITypes interface, and the value would then be need to be the type specified at the selected key from the interface. I can do this just fine using the below code:

const key: keyof ITypes = "num"; // key must be a key from the ITypes interface
const val: ITypes[typeof key] = 1; // val must be the type specified at `"num"` - `number`
obj[key] = val;

The above code works fine, however, now, instead of having key and val as seperate variables, I want to define them in an object, and so I need to define an interface. However, I'm having trouble with the type definition for val. This is what I have tried so far:

interface IUpdatePayload {
  key: keyof ITypes;
  val: ITypes[typeof this.key]; // Type 'any' cannot be used as an index type.
};
const updatePayload: IUpdatePayload = {key: "num", val: "1"}; // should complain that `val` is not a number
obj[updatePayload.key] = updatePayload.val; 

I've tried to self-reference the interface's key type using typeof this.key (which I saw suggested in this answer) however that is erroring with Type 'any' cannot be used as an index type., I'm guessing it's because key hasn't actually been defined to have a value like in the first working example using variables. My question is, is there a way to make this work so that val takes on the type defined by key as specified in the ITypes interface?

2 Answers

You can use a mapped type to generate a union of key/value pairings from any type:

type KVPairs<T, K extends keyof T = keyof T> = 
    K extends keyof T
        ? { key: K, val: T[K] } 
        : never;

You can then create your custom union:

// {key: "num", val: number} | {key: "str", val: string} | {key: "bol", val: boolean}
type IUpdatePayload = KVPairs<ITypes>;

This type also allows you to select a subset of the keys if you want:

// {key: "num", val: number} | {key: "str", val: string}
type NumOrStr = KVPairs<ITypes, "num"|"str">

However, the error isn't always very clear when assigning object literals to the union type:

const obj: IUpdatePayload = { key: "num", val: "1" };
/*
Type '{ key: "num"; val: string; }' is not assignable to type 'IUpdatePayload'.
  Type '{ key: "num"; val: string; }' is not assignable to type '{ key: "bol"; val: boolean; }'.
    Types of property 'key' are incompatible.
      Type '"num"' is not assignable to type '"bol"'.
*/

playground

You could use a generic interface for this:

interface IUpdatePayload<K extends keyof ITypes> {
  key: K;
  val: ITypes[K];
}

const updatePayload: IUpdatePayload<"num"> = {
  key: "num",
  val: "1"
//~~~ Type 'string' is not assignable to type 'number'.
};
obj[updatePayload.key] = updatePayload.val; 

The downside of this is you have to specify the generic argument "num" (the property that you are updating). To avoid this, you could use a helper function that would allow this argument to be inferred for you:

const updateObj = <K extends keyof ITypes>(updatePayload: IUpdatePayload<K>): void => {
  obj[updatePayload.key] = updatePayload.val; 
}
updateObj({key: "num", val: "1"}) // error
updateObj({key: "num", val: 1}) // ok

Playground link

Related