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