You can use generics in your maybeSetNumber()
signature to say that field
is of a generic property key type (K extends PropertyKey
), and target
is of a type with a number
value at that 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 will give the behaviors you want:
maybeSetNumber(foo, "a"); // error!
// ----------> ~~~
// Types of property 'a' are incompatible.
maybeSetNumber(foo, "b"); // okay
Warning: TypeScript isn't perfectly sound, so this will still let you do some unsafe things if you start using types which are 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 make the signature such that the error above is on "a"
and not on foo
. This way is more complicated and requires at least one type assertion since the compiler doesn't understand the implication:
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
This doesn't suffer from the same problem with Oops
,
maybeSetNumber2(o, "x"); // error!
but there are still likely edge cases around soundness. TypeScript often assumes that if you can read a value of type X
from a property then you can write a value of type X
to that property. This is fine until it isn't. In any case either of these will be better than any
.
Playground link to code