typescriptmapped-types

How to map over this within a class field?


Given a field that maps over the class's fields, ignoring itself and ignoring keys with string values:


class Foo {

  x = 1;
  y = 'y';

  $ = {} as { [K in keyof this as K extends '$' ? never : this[K] extends string ? never : K]: number };

  bar() {
    this.$.x
  }

}

This should not error, and should give auto-completion. But this.$.x says:

Property 'x' does not exist on type '{ [K in keyof this as K extends "$" ? never : this[K] extends string ? never : K]: number; }'.ts(2339)

In fact there are no fields in this mapped type. Why? How do we fix it?


Solution

  • The polymorphic this type as implemented in microsoft/TypeScript#4910 is implicitly generic: "The polymorphic this type is implemented by providing every class and interface with an implied type parameter that is constrained to the containing type itself". So it behaves like a generic type parameter inside the class body. And thus TypeScript doesn't really know how to perform arbitrary type operations on it. When you perform such operations on it, either the operation is deferred in which case TypeScript doesn't know what it might be until outside the class when this is specified, or the operation is prematurely resolved which means it's treated like its constraint, which may or may not be appropriate.

    In your example it looks like your conditional type this[K] extends string ? ⋯ : ⋯ is deferred, and this makes your mapped type completely impenetrable to TypeScript. It doesn't even try to figure out why properties might or might not exist on it. So it complains about every key.

    This is a general design limitation of TypeScript with generics (and thus polymorphic this) and especially their interactions with conditional types. See microsoft/TypeScript#57417 for a similar issue. You either have to give up or work around it.


    The easiest workaround is to just choose to eagerly resolve the generic yourself, by replacing this with its constraint, Foo:

    type MyType<T> = 
      { [K in keyof T as K extends '$' ? never : T[K] extends string ? never : K]: number }
    
    class Foo {
        x = 1;
        y = 'y';
        $ = {} as MyType<Foo> // instead of MyType<this>
        bar() {
            this.$.x // okay
        }    
    }
    

    Of course that premature specification means subclasses can't gain magic extra properties on $. Depending on the use cases, you could make the type more complex by intersecting the this-version with the non-this version:

    class Foo {
        x = 1;
        y = 'y';
        $ = {} as MyType<this> & MyType<Foo> // intersect
        bar() {
            this.$.x
        }    
    }    
    
    class Bar extends Foo {
        z = 200;
    }
    const bar = new Bar();
    bar.$.z.toFixed(); // okay, subclass instance sees new property
    

    That still won't work inside the body of subclasses, though. You'd need to manually narrow $ (say with the declare keyword) in each subclass for that to work:

    class Baz extends Bar {
        w = new Date();
        declare $: MyType<this> & MyType<Baz>
        method() {
            this.$.w.toFixed();
        }
    }
    

    No, none of these are perfect.

    Playground link to code