[SOLVED] Can I make a key-value dictionary in TypeScript that's typesafe for every key-value pair?

Issue

This question’s kind of involved, and possibly best approached by exploring a rudimentary state system, so walk with me here for a minute. Suppose I have this state class:

class AccountState {
  public id: string;
  public displayName: string;
  public score: number;
}

From jcalz’s work here, I know I can build a function that references any AccountState property in a typesafe way—I can take a property name and value, and impose the property’s own type restriction on that value using generics, which is pretty impressive:

class Store {
  state = new AccountState();

  mutate<K extends keyof AccountState>(property: K, value: AccountState[K]): void {
    this.state[property] = value;
  }
}

const store = new Store();
store.mutate('displayName', 'Joseph Joestar'); // ok
store.mutate('displayName', 5); // not ok: surfaces the below typescript error
// ts(2345) Argument of type 'number' is not assignable to parameter of type 'string'.

Using the ValueOf<T> in jcalz’s answer, I can also model a sort-of-typesafe key-value dictionary. It’d probably be easiest for me to show you how it works, as well as its shortcomings, in action:

type ValueOf<T> = T[keyof T];

class Store {
  state = new AccountState();

  mutateMany(updates: { [key in keyof AccountState]?: ValueOf<AccountState> }): void {
    Object.keys(updates).forEach(property => {
      const value = updates[property];
      (this.state[property] as any) = value;
    });
  }
}

const store = new Store();
store.mutateMany({ displayName: 'Joseph Joestar', score: 5 }); // ok
store.mutateMany({ displayName: 1000, score: 'oh no' }); // unfortunately, also ok
store.mutateMany({ score: true }); // not ok, surfaces the below error
// ts(2322) Type 'boolean' is not assignable to type 'ValueOf<AccountState>'.
// (if AccountState had a boolean property, this would be allowed)

That second mutateMany() is an issue. As you can see, I can require that the key is some property of AccountState. I can also require that the value corresponds to some property on AccountState, so it has to be string | number. However, there is no requirement that the value corresponds to the property’s actual type.

How can I make the dictionary fully typesafe, so that e.g. { displayName: 'a', score: 1 } is allowed but { displayName: 2, score: 'b' } is not?

I’ve considered declaring an AccountStateProperties interface which simply repeats all those properties and their values, then defining mutateMany(updates: AccountStateProperties), but that would add up to a lot of code duplication for more involved state objects. I didn’t know I could do some of these things until today, and I’m wondering if the typing system has something I can leverage here to make this dictionary fully typesafe without that approach.

Solution

In the mutateMany method, [key in keyof AccountState]?: ValueOf<AccountState>, you’re saying that for any key, the type of the value can be any type that AccountState has. You can see this demonstrated if you try an update with something that’s not in AccountState (like true).

Instead, I believe you want:

mutateMany(updates: { [key in keyof AccountState]?: AccountState[key] })

This says that the value at key should additionally match the type of AccountState at key and not just any of the types of the values for AccountState.

[edit: If you look at that linked answer, the section that starts with "In order to make sure that the key/value pair "match up" properly in a function, you should use generics as well as lookup types…" describes this]

Answered By – willis

Answer Checked By – Mildred Charles (BugsFixing Admin)

Leave a Reply

Your email address will not be published. Required fields are marked *