Utilize generics within your function signature for maybeSetNumber()
to specify that the field
is a generic property key type (K extends PropertyKey
), and the target
is of a type containing a number
value at that specific key (Record<K, number>
using the Record
utility type):
function maybeSetNumber<K extends PropertyKey>(target: Record<K, number>, field: K) {
const num = maybeANumber();
if (num !== undefined) {
target[field] = num;
}
}
This approach will provide you with the desired functionalities:
maybeSetNumber(foo, "a"); // error!
// ----------> ~~~
// Types of property 'a' are incompatible.
maybeSetNumber(foo, "b"); // okay
Please note: TypeScript's soundness isn't perfect, so it may allow unsafe operations when dealing with types narrower than number
:
interface Oops { x: 2 | 3 }
const o: Oops = { x: 2 };
maybeSetNumber(o, "x"); // no error, but could be bad if we set o.x to some number < 1
It is also possible to adjust the signature in a way that the error occurs on "a"
instead of foo
. However, this method is more complex and may require one type assertion as the compiler might not fully grasp the implications:
type KeysMatching<T, V> = { [K in keyof T]: V extends T[K] ? K : never }[keyof T]
function maybeSetNumber2<T>(target: T, field: KeysMatching<T, number>) {
const num = maybeANumber();
if (num !== undefined) {
target[field] = num as any; // need a type assertion here
}
}
maybeSetNumber2(foo, "a"); // error!
// ----------------> ~~~
// Argument of type '"a"' is not assignable to parameter of type '"b"'.
maybeSetNumber2(foo, "b"); // okay
With this alternative, issues like the one seen with Oops
can be avoided,
maybeSetNumber2(o, "x"); // error!
However, there may still be potential edge cases related to soundness. TypeScript often assumes that if a value of type X
can be read from a property, then a value of type X
can also be written to that property. Although this generally holds true, there are exceptions. In any case, both of these methods are preferable to using any
.
Playground link to code