2
votes

Sorry for the confusing title.

I'm trying to use a lookup type similar to the setProperty example in https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-1.html#keyof-and-lookup-types

The lookup type is correctly checked when calling the function but can't be used inside the function. I tried working around this with a type guard, but this doesn't seem to work.

Example:

interface Entity {
  name: string;
  age: number;
}

function handleProperty<K extends keyof Entity>(e: Entity, k: K, v: Entity[K]): void {
  if (k === 'age') {
    //console.log(v + 2); // Error, v is not asserted as number
    console.log((v as number) + 2); // Good
  }
  console.log(v);
}

let x: Entity = {name: 'foo', age: 10 }

//handleProperty(x, 'name', 10); // Error
handleProperty(x, 'name', 'bar'); // Good
// handleProperty(x, 'age', 'bar'); // Error
handleProperty(x, 'age', 20); // Good

TS Playground

Is there any way to make typescript figure this out, without hard-coding a type assertion: (v as number)? At that point in the code, the compiler should be able to infer that v is a number.

1
What do you want to achieve? Mutating function arguments, considered a bad practicecaptain-yossarian
@captain-yossarian I agree. I'll edit the question to reflect my intention. I want to use the argument using the type that I know it to be.wensveen

1 Answers

4
votes

The first problem is that the compiler cannot narrow the type parameter K by checking the value of k inside the implementation of handleProperty(). (See microsoft/TypeScript#24085.) It doesn't even try. Technically, the compiler is correct not to do so, because K extends "name" | "age" does not mean that K is either "name" or "age". It could be the full union "name" | "age", in which case, you cannot assume that checking k has an implication for K and thus T[K]:

handleProperty(x, Math.random() < 0.5 ? "name" : "age", "bar"); // accepted!

Here you can see that the k parameter is of type "name" | "age", and so that's what K is inferred to be. Thus the v parameter is allowed to be of type string | number. So the error inside the implication is correct: k might be "age" and v might still be a string. This completely defeats the purpose of your function and is definitely not your intended use case, but it's a possibility the compiler is worried about.

Really what you'd like to say is that either K extends "name" or K extends "age", or something like K extends_one_of ("name", "age"), (see microsoft/TypeScript#27808,) but there is currently no way to represent that. So generics don't really give you the handle you're trying to turn.

Of course you could just not worry about someone calling handleProperty() with the full union, but you'll need a type assertion inside the implementation like v as number.


If you want to actually constrain callers to the intended use cases, you can use a union of rest tuples instead of generics:

type KV = { [K in keyof Entity]: [k: K, v: Entity[K]] }[keyof Entity]
// type KV = [k: "name", v: string] | [k: "age", v: number];

function handleProperty(e: Entity, ...[k, v]: KV): void {
  // impl
}

handleProperty(x, 'name', 10); // Error
handleProperty(x, 'name', 'bar'); // Good
handleProperty(x, 'age', 'bar'); // Error
handleProperty(x, 'age', 20); // Good
handleProperty(x, Math.random() < 0.5 ? "name" : "age", "bar"); // Error

You can see that the type KV is a union of tuples (created by mapping Entity to a type whose properties are such tuples and then immediately looking up the union of those properties) and that handleProperty() accepts that as its last two arguments.

Great, right? Well unfortunately that does not solve the problem inside the implementation:

function handleProperty(e: Entity, ...[k, v]: KV): void {
  if (k === 'age') {
    console.log(v + 2); // still error!
  }
  console.log(v);
}

This is due to lack of support for what I've been calling correlated union types (see microsoft/TypeScript#30581). The compiler sees the type of the destructured k as "name" | "age" and the type of the destructured v as string | number. Those types are correct, but are not the full story. By destructuring the rest argument, the compiler has forgotten that the type of the first element is correlated to the type of the second element.


So, to get around that, you can just not destructure the rest argument, or at least not until you check its first element. For example:

function handleProperty(e: Entity, ...kv: KV): void {
  if (kv[0] === 'age') {
    console.log(kv[1] + 2) // no error, finally!
    // if you want k and v separate
    const [k, v] = kv;
    console.log(v + 2) // also no error
  }
  console.log(kv[1]);
}

Here we are leaving the rest tuple as a single array value kv. The compiler sees this as a discriminated union and when you check kv[0] (the former k) the compiler will, finally, narrow the type of kv for you so that kv[1] will also be narrowed. It's ugly using kv[0] and kv[1], and while you could partially mitigate this by destructuring after the check of kv[0], it's still not great.


So there you are, a fully type safe (or at least closer to type safe) implementation of handleProperty(). Is it worth it? Probably not. In practice I find that it's usually just better to write idiomatic JavaScript along with a type assertion to quiet the compiler warnings, like you've done in the first place.

Playground link to code