Reputation: 96832
I want to assign a number to an object's field based on some condition like this:
function maybeANumber(): number | undefined {
const n = Math.random();
return n > 0.5 ? n : undefined;
}
function maybeSetNumber(target: any, field: any) {
const num = maybeANumber();
if (num !== undefined) {
target[field] = num;
}
}
This works because I used any
liberally, but how can I type this correctly, so that it detects type errors:
interface Foo {
a: string,
b: number,
}
const foo: Foo = { a: "", b: 0 };
maybeSetNumber(foo, "a"); // Should be a compile-time error.
maybeSetNumber(foo, "b"); // Should be a ok.
Is there any way to do this?
Edit: Important clarification: the field names are static. I don't need it to work with arbitrary strings. I've tried a load of stuff with keyof
but couldn't quite figure it out.
Upvotes: 5
Views: 574
Reputation: 3187
The other answers here are practically useful — I just want to offer a fundamental reason why this isn't possible without type assertions. In order to perform the set target[field]: O[K] = num
, we need to assert that O[K]: number
of course. However:
Unfortunately for us, we want to make an assertion about O[K]
, which is not a single type variable, and the arguments/body can't help out.
The forward flow of constraints from contexts to bodies is common in other, distant languages with type contexts — Haskell comes to mind for example. I'm not familiar enough with typechecker implementation to know if this is a theoretical issue about decidability, but either way, the bottom line is that Typescript's type contexts just aren't powerful enough at the moment to allow us to make this assertion.
Upvotes: 0
Reputation: 1101
In languages without union types, one way to deal with this is multiple return values, in languages which support that (because you're really trying to return two values, a number, and an indicator whether to use the number, you're trying to repurpose the return value's type as an attribute flag attached to the returned number). In that case, return a dummy integer if the "use" flag is false.
I found an example of how to do this in Javascript here: Return multiple values in JavaScript?
Upvotes: -1
Reputation: 47811
I would type maybeSetNumber
like this:
function maybeSetNumber<F extends string>(target: { [key in F]: number }, field: F) {
const num = maybeANumber();
if (num !== undefined) {
target[field] = num;
}
}
Upvotes: 2
Reputation: 329638
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
.
Upvotes: 5