Reputation: 5124
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.
Upvotes: 4
Views: 6186
Reputation: 330456
For mutateMany()
, I think the version that works and doesn't require too much unsafety is to have updates
be of type Pick<AccountState, K>
, where K
is constrained to be keys from AccountState
, and where we're using the Pick<T, K>
utility type. A value of type Pick<AccountState, K>
will have all the properties from AccountState
at the keys in K
.
Here's how one might write it:
class Store {
state = new AccountState();
mutateMany<K extends keyof AccountState>(updates: Pick<AccountState, K>): void {
(Object.keys(updates) as Array<K>).forEach(<P extends K>(property: P) => {
this.state[property] = updates[property];
});
}
}
The only unsafe thing going on here is that we assert that Object.keys(updates)
will return only the keys in K
. The compiler doesn't actually know this, because object types in TypeScript are not closed; it may well be that updates
has more properties than the compiler knows about. See this question and answer for more info.
Let's see how it works:
const store = new Store();
store.mutateMany({ displayName: 'Joseph Joestar', score: 5 }); // ok
store.mutateMany({ displayName: 1000, score: 'oh no' }); // error!
// --------------> ~~~~~~~~~~~ ----> ~~~~~
// number isn't string, string isn't number
store.mutateMany({ displayName: "okay", randomProp: 123 }); // error!
// -----------------------------------> ~~~~~~~~~~~~~~~
// Object literal may only specify known properties
Looks good. The errors show up where we expect them.
Note that the issue with extra keys in updates
is prevented above if you pass in an object literal, due to excess property checks. But if you don't use a fresh object literal, it's possible to do weird things:
const badMutation = {
displayName: "okay",
randomProp: 123
}
const aliasedMutation: { displayName: string } = badMutation;
store.mutateMany(aliasedMutation); // no error here
This is unlikely, so you might not care. If you do care, the type system in TypeScript can't really stop it, so you'd need to protect yourself at runtime with some extra check like
if (["id", "displayName", "score"].includes(property))
this.state[property] = updates[property];
Upvotes: 2
Reputation: 3390
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]
Upvotes: 6