cpcallen
cpcallen

Reputation: 2065

Why is the type {[x: string]: this} not equivalent to Record<string, this> in TypeScript class member declarations?

Background

Last week I asked a question about how to declare a class member that is a function whose parameter is covariant with the class, for which using the polymorphic this type was a perfect solution.

My actual code has a class with a member which is a dictionary of such functions. In adapting the solution, ran in to an unexpected type error.

Question

Given the following class declaration:

class C {
    public dict1: {[x: string]: this} = {};    // Error: A 'this' type is available only in a non-static member of a class or interface.
    public dict2: Record<string, this> = {};   // OK
    public dict3: {[P in string]: this} = {};  // OK?!
}

Why does the declaration of dict1, using an index signature, provoke the error

A 'this' type is available only in a non-static member of a class or interface

but the seemingly-equivalent declarations of dict2 (using the Record<> utility type), and dict3 (using a different index signature based on the definition of Record<>) not provoke the same error?

Upvotes: 3

Views: 148

Answers (1)

jcalz
jcalz

Reputation: 327859

See microsoft/TypeScript#47868 for a canonical answer to most of this question. The polymorphic this type, as implemented in microsoft/TypeScript#4910, generally refers to the type of the this value in the scope in which it is mentioned. For a type like

interface Foo { a: this }

this refers to a Foo or some subtype of Foo, so you could implement it like

class Bar implements Foo { a = this; }
class Baz extends Bar { z = "abc" }
const baz = new Baz();
console.log(baz.a.a.a.z.toUpperCase()); // "ABC"

So baz, baz.a, baz.a.a, etc. are all of type Baz. That's great.

But for a type like

interface Qux { a: { b: this } } // error!
// -------------------> ~~~~
// only in an interface property or a class instance member 

what does this refer to? What's the relevant scope? One could possibly expect it to refer to the outer scope, and therefore it's Qux (or a subtype), but maybe it refers to just the inner scope and thus it's Qux["a"] (or a subtype). It's ambiguous.

TypeScript sidesteps the ambiguity by only allowing this to be used in an interface property or a class instance member, with no intervening scopes. That's why the above is an error.

In order to specify which one you mean, you have to use some indirection, possibly with generics. If you want the outer scope, you can do this:

interface B<T> { b: T }
interface Qux { a: B<this> }

Now this can only refer to a subtype of Qux. You can implement it with a class like

class Quux implements Qux {
    a = { b: this }
    z = "abc";
}
const q = new Quux();
console.log(q.a.b.a.b.z.toUpperCase()) // "ABC"

So q, q.a.b, q.a.b.a.b, etc, are all of type Quux.

If you want the inner scope, you can do this:

interface B { b: this }
interface Qux { a: B }

Now this can only refer to a subtype of B. You can implement it like

class Quux implements Qux {
    a = { get b() { return this }, z: "abc" };
}
const q = new Quux();
console.log(q.a.b.b.b.z.toUpperCase()) // "ABC"

Here I've used a getter but you could do it with another class if you want. Anyway now q.a, q.a.b, q.a.b.b, etc, are of type Quux["a"].


For your example,

class C {
    dict: { [x: string]: this } = {};
}

you've got an index signature, which behaves like a range of keys in an object type. An index signature can sit alongside other properties in an object type (e.g., {[k: string]: string; foo: "bar"}).

And { [x: string]: this } is just as ambiguous as { a: this }. Does this refer to an instance of C or just to an the dict property of C? It looks like you intended the former. Thus we need some kind of indirection. You could do so like

type Dict<T> = { [x: string]: T };
class C {
    dict: Dict<this> = {}; // okay
}

But of course you don't have to write your own Dict type. The Record utility type can be pressed into service this way, where Record<string, T> is essentially the same thing:

class C {
    dict: Record<string, this> = {}; // okay
}

And finally, if you define a mapped type inline like

class C {
    dict: { [K in string]: this } = {}; // okay
}

it also works, because apparently a mapped type does not introduce its own scope. Note that while the syntax for mapped types looks similar to that of index signatures, they are not the same. (The question refers to this as an index signature, but it is not an index signature.) An index signature can be thought of a piece of an object type (e.g., other properties and signatures can appear inside an object type with it), while a mapped type is its own separate thing (e.g., the curly braces are part of the mapped type; you can't add other properties). See this answer to another question for more about that.


So there you go. You can only use polymorphic this in a place where the scope is not ambiguous according to the compiler. You can't do it inside a nested object property, or an index signature, but you can do it with indirection, generics, and apparently mapped types.

Playground link to code

Upvotes: 1

Related Questions