Assume that we have some class that has an important generic variable T
and another class we have two fields, one wrapped, and one not:
class Wrapper<V> {
constructor(public value: V) {
}
clone(): Wrapper<V> {
return new Wrapper(this.value);
}
}
class SomeClass {
value1 = new Wrapper(1);
value2 = 2;
}
Then, we want a method wrapperValue
which, when given an object (obj
) and a field name (name
) returns the value of wrapper accessed by obj[name].value
. It is important that the return type is correct. So far, this is what I have managed to come up with:
type WrapperKeyOf<S> = keyof {
[K in keyof S as S[K] extends Wrapper<any> ? K: never]: any
}
type WrapperValueTypeOf<W> = W extends Wrapper<infer V> ? V : never;
function wrapperValue<S, K extends WrapperKeyOf<S>>(
obj: S,
name: K,
): WrapperValueTypeOf<S[K]> {
const wrapper: Wrapper<WrapperValueTypeOf<S[K]>> = obj[name];
return wrapper.value;
}
wrapperValue(new SomeClass(), "value1");
The type WrapperKeyOf
restricts name
to only to be keys of S
where S[T]
is a Wrapper
, and WrapperValueTypeOf<S[T]>
gets the wrapper type.
The TypeScript compiler produces the following error:
Type 'S[K]' is not assignable to type 'Wrapper<WrapperValueTypeOf<S[K]>>'.
Type 'S[keyof { [K in keyof S as S[K] extends Wrapper<any> ? K : never]: any; }]' is not assignable to type 'Wrapper<WrapperValueTypeOf<S[K]>>'.
Type 'S[string] | S[number] | S[symbol]' is not assignable to type 'Wrapper<WrapperValueTypeOf<S[K]>>'.
Type 'S[string]' is not assignable to type 'Wrapper<WrapperValueTypeOf<S[K]>>'.
It seems that the fact that K
had to be a key of S
which accessed a Wrapper
gets lost. Is there any way to preserve this information somehow?
CodePudding user response:
Unfortunately the compiler is unable to perform the kind of abstract generic type analysis necessary to verify that T[KeysMatching<T, V>]
is assignable to V
for generic T
, where KeysMatching<T, V>
is the union of property keys of T
whose property values are assignable to V
, as described in this SO question. The problem is that KeysMatching<T, V>
can only be implemented with a conditional type (somewhere in there you'll have a check like T[K] extends V ? K : never
), and the compiler essentially treats conditional types that depend on generic type parameters as opaque, and chooses to defer evalutation of them until the generic type parameters are specified with some specific type. This is effectively a design limitation of TypeScript, and is documented at microsoft/TypeScript#30728 and microsoft/TypeScript#31275 (and probably others).
Since your WrapperKeyOf<T>
is an implementation of KeysMatching<T, Wrapper<any>>
, it means that the compiler cannot see that T[WrapperKeyOf<T>]
will be assignable to Wrapper<any>
.
However, the compiler can tell that when you index into a mapped type of the form {[P in K]: V}
(or the equivalent use of the Record<K, V>
utility type) with a key K
that you'll get something assignable to V
.
So if you rephrase your requirement in terms of constraining obj
instead of constraining name
, you'll be able to get the sort of type safety guarantees you're looking for:
function wrapperValue<V, K extends PropertyKey>(
obj: Record<K, Wrapper<V>>,
name: K,
): V {
const wrapper = obj[name];
return wrapper.value; // okay
}
Here we let name
be of generic type K
which can be any key-like type, and then we restrict obj
to be something with a key K
and whose value at that key is of type Wrapper<V>
for generic type parameter V
. Now the compiler knows that obj[name].value
is of type V
, so the implementation is error-free.
And your calls to wrapperValue()
are still safe (although when you make a mistake the error will now be on obj
instead of name
):
const result = wrapperValue(new SomeClass(), "value1"); // okay
console.log(result.toFixed(1)); // 1.0
wrapperValue(new SomeClass(), "value2"); // error!
// --------> ~~~~~~~~~~~~~~~
// Type 'number' is not assignable to type 'Wrapper<number>'
wrapperValue(new SomeClass(), "value3"); // error!
// --------> ~~~~~~~~~~~~~~~
// Property 'value3' is missing in type 'SomeClass'