Constrain one Typescript generic parameter based on properties of another?
Asked Answered
A

2

4

I'm trying to write a function that takes an object and a (string) key, then operates on a property of the object. This is easy:

function f<T extends any, K extends keyof T>(obj: T, key: K) {
  const prop = obj[key]; // prop is typed as T[K]
}

I would like to constrain the key passed to the call, at compile-time, based on the type of T[K]. I tried this:

function f<T extends any, K extends keyof T>(obj: T, key: T[K] extends number ? K : never) {
  obj[key] = 5; // error, "is not assignable to" etc
}

prop is typed as T[T[K] extends number ? K : never] which reads to me like it should collapse to just number, but it does not.

My goal is to be sure that obj[key] is typed as number, inside the function, and also have calls like f({a: true}, "a") flagged as an error. Is this possible? I thought I might need to move the constraint from the function parameter declaration, to the generic parameter declaration, but I couldn't figure out the syntax.


ETA yet again: Playground example -- updated to try the approach suggested by @reactgular in a comment:

type AssignableKeys<T, ValueType> = {
  [Key in keyof T]-?: ValueType extends T[Key] | undefined ? Key : never
}[keyof T];

type PickAssignable<T, ValueType> = Pick<T, AssignableKeys<T, ValueType>>;

type OnlyAssignable<T, ValueType> = {
  [Key in AssignableKeys<T, ValueType>]: ValueType
};

interface Foo {
  a: number;
  b: string;
  nine: 9;
  whatevs: any;
}

type FooNumberKeys = AssignableKeys<Foo, number>; // "a" | "whatevs"
type CanAssignNumber = PickAssignable<Foo, number>; //  { a: number; whatevs: any; }
type DefinitelyJustNumbers = OnlyAssignable<Foo, number>; //  { a: number; whatevs: number; }

function f1<T>(obj: OnlyAssignable<T, number>, key: keyof OnlyAssignable<T, number>) {
  obj[key] = Math.random(); // Assignment is typed correctly, good
}

function f2<T extends object, K extends keyof PickAssignable<T, number>>(obj: T, key: K) {
  obj[key] = Math.random(); // Uh oh, Type 'number' is not assignable to type 'T[K]'.(2322)
}

declare const foo: Foo;
f1(foo, "a"); // Allowed, good
f1(foo, "whatevs"); // Allowed, good
f1(foo, "nine"); // Uh oh, should error, but doesn't!
f1(foo, "b"); // Error, good

f2(foo, "a"); // Allowed, good
f2(foo, "whatevs"); // Allowed, good
f2(foo, "nine"); // Error, good
f2(foo, "b"); // Error, good

In the Playground, DefinitelyJustNumbers shows a tooltip of {a: number; whatevs: number} -- anything that I can assign a number to is explicitly typed as number. This fixes the assignment inside the function body, but fails to detect the fact that nine is only a subset of number and so should not be allowed.

CanAssignNumber shows a tooltip of {a: number; whatevs: any}, correctly excluding nine because it's not assignable to number. This looks good, but still doesn't fix assignment inside the function f2.

Attraction answered 20/10, 2020 at 16:11 Comment(2)
Does this answer your question? How do I require a keyof to be for a property of a specific type?Stirk
@Stirk I updated my post to address this, but I forgot that editing the post probably didn't notify you. So: ping!Attraction
C
2

Just use a type assertion, your concern should be the call site, that is typed correctly and gives you errors where it should. The implementation can't really be typed correctly if you want to assign a specific value inside the function.

You can make the result of T[K] extend number, for example but adding a constraint to T of Record<K, number>, but we still not be able to assign concrete values to obj[key]

type KeyOfType<T, ValueType> = 
  { [Key in keyof T]-?: T[Key] extends ValueType | undefined ? Key : never }[keyof T]

function f<T extends Record<K, number>, K extends KeyOfType<T, number>>(obj: T, key: K, value: T[K]) {
    let r = obj[key]; 
    r.toExponential(); // seems numberish, but it `T[K]` which does extend number, but might not be number
    obj[key] = obj[key] // T[K] is assignable to T[K]
    obj[key] = value; // even if it is a parameter 
    obj[key] = 5; // still an error
}

declare const foo: Foo;
f(foo, "a", 1); // Allowed, good
f(foo, "b", 2); // Error, good

const other: { 
  a: 1
} = {
  a: 1
}
f(other, "a", 1) // this will break the type of other because of obj[key] = 5

Playground Link

The reason this is so, is the last example f(other, "a", 1). Here a in other has type 1, which does extend number, so f(other, "a", 1) is a valid call to f, but inside we want to assign other[key] = 5. This would break the type of other. The problem here is that there is no way so specify that T[K] has an upper constraint of number just a lower constraint.

Conservation answered 21/10, 2020 at 12:11 Comment(3)
One: the input type T needs to be allowed to have properties that aren't number, so Record isn't helpful. Two: I want an "upper constraint" on the key K such that some type (let's call it V) is assignable to T[K]. I don't understand why it's not possible to express that constraint. I updated the question with a new Playground example of a better (but still failed) attempt.Attraction
@Attraction There is no support in the type system for upper bounds. When you express a constraint such as T extends number, T can be number, as well as any sub type of number, which can be any number literal type such as 1, 2 etc. You can get the compiler to understand that T[K] extends number ,but that only means T[K] must be a subtype of number not necessarily that it is number. So the assignment obj[key] = 1 is not sound because obj[key] might be of type number, 1 or 2, and for one of these the assignment would be incorrectConservation
In the rewrite above, though, I have number extends T or something like it. Of course this is only possible in the "r-value" of the mapped type, not in the generic constraint, but the mapped type in my latest question update does match the keys I wanted, so I was hoping it would sort of percolate through. It sounds like I'll still need the assertion, but after the changes I just made, I can't come up with a counterexample showing why it's wrong/dangerous.Attraction
C
3

You should extends from keys that result in a value of type number.

export type PickByValue<T, ValueType> = Pick<
  T,
  { [Key in keyof T]-?: T[Key] extends ValueType ? Key : never }[keyof T]
>;

function f<T extends object, K extends keyof PickByValue<T, number>>(obj: T, key: K) : T[K] {
  return obj[key]
}

Edit: What you're trying to do is not possible in TS AFAIK, and sometimes there's a good reason for that. let's suppose you have below code:

function f<T extends object, K extends keyof PickByValue<T, number>>(obj: T, key: K) {
    obj[key] = 5; // Type 'number' is not assignable to type 'T[K]'.
} 

const obj = {a: 9} as const;

f(obj, "a")

For example in the above scenario, value of property a is a number however it's not of type number but of type 9. there's no way for typescript to know this before hand. in other scenarios, the only thing that comes to my mind is using Type Assertions.

Colour answered 20/10, 2020 at 21:35 Comment(4)
This is a helpful way to express what I'm trying to do, thanks! Unfortunately, it doesn't actually work.Attraction
I updated the question with more detail. Too late to edit the above comment, sorry.Attraction
Per Titians's answer below, what I want is an "upper constraint" (upper bound?) on the type of T[K], so that the constraint-type (number in the example) is always assignable to T[K]. The updated example in my question would catch your counterexample and flag it as invalid. (I still can't get assignability to work in the function body, though.)Attraction
@Attraction I really can't think of a way to achieve what you want to achieve. with current TS power, I don't think you can do better than this. it would be a good idea to question why you need to reassign your input in the first place, usually there are better ways.Colour
C
2

Just use a type assertion, your concern should be the call site, that is typed correctly and gives you errors where it should. The implementation can't really be typed correctly if you want to assign a specific value inside the function.

You can make the result of T[K] extend number, for example but adding a constraint to T of Record<K, number>, but we still not be able to assign concrete values to obj[key]

type KeyOfType<T, ValueType> = 
  { [Key in keyof T]-?: T[Key] extends ValueType | undefined ? Key : never }[keyof T]

function f<T extends Record<K, number>, K extends KeyOfType<T, number>>(obj: T, key: K, value: T[K]) {
    let r = obj[key]; 
    r.toExponential(); // seems numberish, but it `T[K]` which does extend number, but might not be number
    obj[key] = obj[key] // T[K] is assignable to T[K]
    obj[key] = value; // even if it is a parameter 
    obj[key] = 5; // still an error
}

declare const foo: Foo;
f(foo, "a", 1); // Allowed, good
f(foo, "b", 2); // Error, good

const other: { 
  a: 1
} = {
  a: 1
}
f(other, "a", 1) // this will break the type of other because of obj[key] = 5

Playground Link

The reason this is so, is the last example f(other, "a", 1). Here a in other has type 1, which does extend number, so f(other, "a", 1) is a valid call to f, but inside we want to assign other[key] = 5. This would break the type of other. The problem here is that there is no way so specify that T[K] has an upper constraint of number just a lower constraint.

Conservation answered 21/10, 2020 at 12:11 Comment(3)
One: the input type T needs to be allowed to have properties that aren't number, so Record isn't helpful. Two: I want an "upper constraint" on the key K such that some type (let's call it V) is assignable to T[K]. I don't understand why it's not possible to express that constraint. I updated the question with a new Playground example of a better (but still failed) attempt.Attraction
@Attraction There is no support in the type system for upper bounds. When you express a constraint such as T extends number, T can be number, as well as any sub type of number, which can be any number literal type such as 1, 2 etc. You can get the compiler to understand that T[K] extends number ,but that only means T[K] must be a subtype of number not necessarily that it is number. So the assignment obj[key] = 1 is not sound because obj[key] might be of type number, 1 or 2, and for one of these the assignment would be incorrectConservation
In the rewrite above, though, I have number extends T or something like it. Of course this is only possible in the "r-value" of the mapped type, not in the generic constraint, but the mapped type in my latest question update does match the keys I wanted, so I was hoping it would sort of percolate through. It sounds like I'll still need the assertion, but after the changes I just made, I can't come up with a counterexample showing why it's wrong/dangerous.Attraction

© 2022 - 2024 — McMap. All rights reserved.