In order for this to work, you want to make the function generic in the type of the key
input. Let's alter your example so that it only works for setting properties of User
, and make a user
of that type to modify with the function:
const user: User = {
name: "",
age: 0,
friends: [],
hasPet: false
}
function setUserValue<K extends keyof User>(key: K, value: User[K]) {
user[key] = value;
}
Here you can see that the function is generic in K
which has been constrained to keyof User
. The key
input is of type K
, and the value
input is of type User[K]
, an indexed access type meaning "the property type of User
at a key of type K
". Let's see how it works:
setUserValue("age", true); // error, boolean isn't a number
setUserValue("age", 25) // okay
Looks good. The compiler infers K
to be the literal type "age"
and then wants value
to be of type User["age"]
which is number
.
This is the end of the simple version of the answer.
That was the standard solution to this problem; do note that it is still possible to do unsafe things with this function, although they are less likely. For example, when key
is of a union type, things can go wrong:
const key = Math.random() < 0.5 ? "age" : "hasPet";
setUserValue(key, true); // no error! But this has a 50% chance of doing something bad
There is currently no direct way in TypeScript to say that we want K
to be exactly one element from the keyof User
union and not allow it to be a union type itself. There's a feature request at microsoft/TypeScript#27808 asking for support for that. Right now you need to accept key
being a union (or prevent it in a complicated way I won't get into here).
The real problem is that indexed access types T[K]
are only safe for reading properties, because T[K1 | K2]
becomes T[K1] | T[K2]
. But for writing you really want T[K1 | K2]
to be treated like T[K1] & T[K2]
... that is, unions in the key type should become intersections in the "writing indexed access" type.
You can express such a type function in TypeScript as follows:
type WritingIdx<T, K extends keyof T> =
{ [P in K]: (x: T[P]) => void }[K] extends (x: infer I) => void ? I : never;
(Here I'm using essentially the same technique as here to turn unions to intersections)
And then you could declare setUserValue()
to have value
be of type WritingIdx<User, K>
instead of User[K]
:
function setUserValue<K extends keyof User>(key: K, value: WritingIdx<User, K>): void;
function setUserValue<K extends keyof User>(key: K, value: User[K]) {
user[key] = value;
}
Note that I had to make this a one-call-signature overload because one drawback of WritingIdx<User, K>
is that the compiler cannot verify that user[key] = value
is safe when value
is of that type. So inside the implementation I'm widening it to User[K]
.
Let's check it out:
setUserValue("age", true); // error, boolean isn't a number
setUserValue("age", 25) // okay
setUserValue(key, true); //error!
If we imagine that User
had some property with overlapping types:
interface User {
name: string;
shoeSize: string | number;
}
Here shoeSize
can be either string
or number
, but name
can only be a string
. Then setUserValue()
on both of them should only accept the intersection, which is string
:
const nameOrShoeSize = Math.random() < 0.5 ? "name" : "shoeSize"
setUserValue(nameOrShoeSize, "hasToBeAString"); // okay
setUserValue(nameOrShoeSize, 123); // error
So, do you need this more complicated version of the answer? Probably not, if the key
parameter is only ever going to be a string literal. Even if unions show up sometimes, you might not want to spend the energy dealing with a complex and pedantic code base. TypeScript generally allows unsafe things in the name of developer convenience, so there are many other "holes" in the type system. But I figured I'd at least talk about the issues involved with a generic property writer function.
Playground link to code