Infer type of _arr in class based on method usages in typescipt

If I have something like

class A{
  _arr: /* dont know what to write here*/ = []
  addEl<T>(el: T): {
    this._arr.push(el)
  }
}

and I want to use it like this

const instance = new A();
instance.addEl<"whatever">("whatever");
// type of instance._arr should be ["whatever"]
instance.addEl<number>(42)
// type of instance._arr should be ["whatever", number]

is that possible? And what would I have to do for the type of _arr?


Solution 1:

As asked, this is impossible. TypeScript's type system doesn't model arbitrary mutations of a value's type. If a variable is annotated as type 0 then it can't later be changed to a value of type 1. The compiler does perform narrowing via control flow analysis whereby the type of a variable can be seen to get more specific based on certain checks or operations, and this does give the compiler some minor ability to track state changes of a variable... but it's quite limited. If you knew for a fact that every call to instance.addEl() would only narrow the type of instance, then you could try to make addEl() an assertion method, but these are brittle and hard to use. And that's not even available to you, since tuple types have a known length property, and calling addEl() would effectively make instance._arr.length change from a value of type 0 to a value of type 1, something the compiler does not allow:

const instance = new A();
instance._arr // should be []
instance._arr.length // should be 0

instance.addEl<"whatever">("whatever");
instance._arr // should be ["whatever"]
instance._arr.length // should be 1, but this is impossible

Rather than trying to refactor your code to use control flow analysis, my suggestion here would be to embrace TypeScript's strong type system and forget about mutating state. So instead of having a single instance whose state changes after each call to addEl(), you have immutable instances which yield new instances when you call addEl(). It could look like this:

const instance0 = A.emptyInstance;
instance0._arr // []
instance0._arr.length // 0

const instance1 = instance0.addEl<"whatever">("whatever");
instance1._arr // ["whatever"]
instance1._arr.length // 1

const instance2 = instance1.addEl<number>(42);
instance2._arr // ["whatever", number]
instance2._arr.length // 2

That's not very different from your original example, and now TypeScript can definitely represent this. Here's how I'd implement it:

class A<T extends any[]>{
  static emptyInstance = new A<[]>([]);
  private constructor(public _arr: T) { }
  addEl<U>(el: U): A<[...T, U]> {
    return new A([...this._arr, el]);
  }
}

Here the A class is generic in T, the type of the _arr property. (Note that I'm using _arr as a parameter property, so it exists both as a property of the instance and a parameter to the constructor.)

The type of addEl() uses variadic tuple types to show that the result will be a new instance of A where the tuple type is like T with an extra element of type U (the type of el) appended to it. While you could just push() the element onto the existing _arr property and return this, you'd then have to remember never to reuse an instance variable after you call addEl(). By returning a new instance, you're ensuring that the values are immutable to match the immutability of the types.

This version of the code lends itself to method chaining, where you don't hold onto intermediate instances unless you need them:

const allAtOnce = A.emptyInstance.addEl(true).addEl(0).addEl(new Date);
// const allAtOnce: A<[boolean, number, Date]>

If this sort of refactoring for immutability works for your use case, great!

If not, then you probably need to give up on having the compiler track the exact current state of the _arr property and fall back to an unordered array of unknown elements, and any checking will need to take place only at runtime.

// fallback 😢
class A {
  _arr: any[] = []
  addEl(el: any) {
    this._arr.push(el)
  }
}

Playground link to code