Timmmm
Timmmm

Reputation: 96832

Type safe field assignment

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

Answers (4)

concat
concat

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:

  • The only static constraints on generics must all be contained in the angle brackets in the function head;
  • These can only take the form of a single generic type variable and a suptyping relation; and
  • The language's design applies constraints forward from the angle brackets to the rest of the body, but not the other way around: i.e. it's impossible to create a constraint in the body or the arguments that will imply anything backwards about the generics in those angle brackets.

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

John Bayko
John Bayko

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

GOTO 0
GOTO 0

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;
  }
}

Playground link

Upvotes: 2

jcalz
jcalz

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.

Playground link to code

Upvotes: 5

Related Questions