3

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?

Napoleon
  • 340
  • 3
  • 13
  • So the whole `value2` thing is irrelevant? – T.J. Crowder Apr 29 '22 at 08:30
  • 1
    Sort of. I included it to show, that there may be things on `S` which are not necessarily of type `Wrapper – Napoleon Apr 29 '22 at 08:40
  • 1
    I just can't quite get it working. It's weird, I feel like I'm missing something obvious. I'll be keen to see the answers! – T.J. Crowder Apr 29 '22 at 09:51
  • 1
    It's really weird, everything seems to be correctly defined and used, maybe we are overlooking it somehow, Interesting question! – Bishwajit jha Apr 29 '22 at 11:10
  • 3
    The compiler cannot follow the abstract generic type analysis you are performing here; see [ms/TS#30728](https://github.com/microsoft/TypeScript/issues/30728) for a description of what's happening. The supported way to use mapped types and indexed access types instead of conditional types. Like [this](https://tsplay.dev/w1yD2w). If that addresses your question I can write up an answer; if not, what am I missing? – jcalz Apr 30 '22 at 03:21
  • @jcalz - Brilliant, I never thought to come at it from the other direction (the key) like that. – T.J. Crowder Apr 30 '22 at 07:57
  • 1
    @Napoleon - [Here's](https://tsplay.dev/mb0DPw) a version of jcalz's playground showing the return type and what happens when you use a non-`Wrapper` key or a completely invalid key (both result in errors). – T.J. Crowder Apr 30 '22 at 07:57

1 Answers1

3

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'

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks for the detailed answer. Those references we also extremely useful! I didn't think of restricting the object in that way. I'll mark this as the answer :) – Napoleon May 02 '22 at 07:39