Home > Software engineering >  Can TypeScript extend an instance's type after calling the instance's method?
Can TypeScript extend an instance's type after calling the instance's method?

Time:01-22

I have a JS library that I'm trying to create types for, imagine the following class:

class Table {

  register(...fields) {
    fields.forEach(field => {
      Object.defineProperty(this, field, {
        get: () => {
          console.log(`Accessed ${field}`);
          return 1;
        },
      })
    })
  }

}

and then you could use Table this way:

let table = new Table(); 
table.register(["uuid", "name", "url"]);
table.name; // has a value of 1 and prints "Accessed name"

Writing a declaration for the class seems easy

class Table {
  register(...fields: string[]): void;
}

However, I don't know whether it's possible for TypeScript to extend the instance's type after calling table.register().

let table = new Table(); 
table.register(["uuid", "name", "url"]);
table.name; // TypeScript error, `name` is not a field of Table

I know it's possible to change the JS code to return the instance itself and then TypeScript could simply return a different type, but in the case the function doesn't return anything, is it possible to tell TypeScript that the instance's type has slightly changed after the call?

CodePudding user response:

You can turn void-returning functions and methods into assertion functions by annotating their return type with an asserts predicate. This allows the compiler to interpret a call to that function as a narrowing of the apparent type of one of the arguments (or this in the case of an assertion method).

Adding properties to an existing type is considered a narrowing, according to TypeScript's structural type system. That's because object types are open/extensible and not sealed/exact; given types interface Foo {x: string} and interface Bar extends Foo {y: string}, you can say that every Bar is a Foo but not vice versa, and therefore that Bar is narrower than Foo. (Note that there is a longstanding open request to support exact types at microsoft/TypeScript#12936, and people often are confused into thinking that TypeScript types are exact because of excess property warnings on object literals; I won't digress further here, but the main point is that extra properties constitute a narrowing and not an incompatibility).

So, because register() is a void-returning method which serves to add properties to this, you can get the behavior you're looking for by making register() into an assertion method.


Here's one way to do it:

class Table {
  register<P extends string>(...fields: P[]
  ): asserts this is this & Record<P, 1> {
    fields.forEach(field => {
      Object.defineProperty(this, field, {
        get: () => {
          console.log(`Accessed ${field}`);
          return 1;
        },
      })
    })
  }
}

The return type asserts this is this & Record<P, 1> implies that a call to table.register(...fields) will cause the type of table (the value called this in asserts this) to be narrowed from whatever it starts as (the type called this in this & Record<P, 1>) to the intersection of its starting type with Record<P, 1> (using the Record<K, V> utility type), where P is the union of string literal types of the values passed in as fields.

That means that by starting with table of type Table, a call to table.register("x", "y", "z") should narrow table to Table & Record<"x" | "y" | "z", 1>, which is equivalent to Table & {x: 1, y: 1, z: 1}.


Let's see it in action, before exploring some important caveats:

let table: Table = new Table();
// ----> ^^^^^^^ <--- this annotation is REQUIRED for this to work
table.register("uuid", "name", "url");
table.name; // has a value of 1 and prints "Accessed name"
table.hello; // error
table.register("hello");
table.hello; // okay

That's more or less what you wanted, I think. The compiler allows you to access table.name after the call the table.register("uuid", "name", "url"). I also add a "hello" field, and you can see that the compiler is unhappy about accessing it before it is created, but happy afterward.


So, hooray. Now for the caveats:

The first one is shown above; you are required to annotate the table variable with the Table type in order for its assertion method to behave. If you don't, you get this awful thing:

let table = new Table();
table.register("uuid", "name", "url"); // error!
//~~~~~~~~~~~~ <-- Assertions require every name in the
// call target to be declared with an explicit type annotation

This is a basic limitation with assertion functions and assertion methods. According to microsoft/TypeScript#32695, the PR that implemented assertion functions (emphasis mine):

A function call is analyzed as an assertion call ... when ... each identifier in the function name references an entity with an explicit type ... An entity is considered to have an explicit type when it is declared as a function, method, class or namespace, or as a variable, parameter or property with an explicit type annotation. (This particular rule exists so that control flow analysis of potential assertion calls doesn't circularly trigger further analysis.

And microsoft/TypeScript#33622 is the PR that implemented the particular error message above (before this it would just silently fail to narrow). It's not great, and it bites people every so often.

It would be great if it could be fixed, but according to this comment on the related issue microsoft/TypeScript#34596:

The issue here is that we need the information about the assertness of a function early enough in the compilation process to correctly construct the control flow graph. Later in the process, we can detect if this didn't happen, but can't really fix it without performing an unbounded number of passes. We warned that this would be confusing but everyone said to add the feature anyway

  • Related