typescriptclassmethodsinstancedefineproperty

Can TypeScript extend an instance's type after calling the instance's method?


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?


Solution

  • 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 🙃

    So I guess if you want this to work, you're going to need an explicit type annotation.


    Also, a general problem with control flow analysis is that its effects do not cross function boundaries. You can read all about it at microsoft/TypeScript#9998. This implies that closures over narrowed values will lose their narrowing, perhaps surprisingly:

    let table: Table = new Table();
    table.register("hello");
    table.hello; // okay
    
    function doSomething() {
      table.hello; // error!
    }
    

    Inside doSomething(), the type of table has been widened back to Table. The standard workaround for such cases is to "save" the narrowed state by assigning the narrowed value to a new const variable, and then use the const inside the function:

    const _table = table;
    function doSomething2() {
      _table.hello; // okay
    }
    

    This works because _table is assigned from table, whose apparent type at the time of the assignment contains a hello property. And therefore _table will always and forever have such a property, even inside closures.

    And so, that's great, but... well, it's not very much different from having the register() function return a new value of the narrowed type and having you use the new value instead of the old one, which is the approach you mentioned in the question.


    So depending on how you use Table instances, assertion methods might be more trouble than they're worth. Still, they do exist, and are exactly what you were asking about in your question, and they do kind of sort of work sometimes, so you should keep them in mind!

    Playground link to code