Home > Back-end >  How to setup typescript generics in class constructors and functions
How to setup typescript generics in class constructors and functions

Time:08-22

I have the following base interface (Animal) and two implementations (Dog and Cat) where each implementation has its own properties (DogProps and CatProps).

interface Animal<T> {
  props: T;
  eat(): void;
}

interface DogProps {
  color: string;
  breed: string;
}

class Dog implements Animal<DogProps> {
  constructor(public readonly props: DogProps) {}
  eat() {}
}

interface CatProps {
  color: string;
  lives: number;
}

class Cat implements Animal<CatProps> {
  constructor(public readonly props: CatProps) {}
  eat() {}
}

Next up, I have a simulator class that can receive an optional initial animal (or set the default one which is a Dog). Users can also use the simulator to change the animal to anything else (e.g. Cat) at any time. Interface Animal<T> is public, so the user can define its own new implementation, e.g. Bird<BirdProps> and run it through the simulator.

I have issues with how to define the simulator to be able to take any implementation of Animal<T> without knowing about T (properties). I tried with these two but it is not working:

interface SimulatorSettings<T> {
  initialAnimal: Animal<T>;
  simulationSteps: number;
}

class SimulatorTry1<T> {
  private _animal: Animal<T>;

  constructor(settings?: Partial<SimulatorSettings<T>>) {
    // Issue 1: Type 'Dog | Animal<T>' is not assignable to type 'Animal<T>'
    this._animal = settings?.initialAnimal ?? new Dog({ color: 'white', breed: 'samoyed' });
    this.doStuff(settings?.simulationSteps ?? 100);
  }

  get animal(): Animal<T> {
    return this._animal;
  }

  setAnimal(animal: Animal<T>, settings: Omit<SimulatorSettings<T>, 'initialAnimal'>) {
    this._animal = animal;
    this.doStuff(settings.simulationSteps);
  }

  private doStuff(steps: number) {}
}

class SimulatorTry2 {
  // Issue 1: Unable to set <T> here because T is undefined
  private _animal: Animal<any>;

  // Issue 2: Unable to set "constructor<T>": Type parameters cannot appear on a constructor declaration.
  constructor(settings?: Partial<SimulatorSettings<T>>) {
    this._animal = settings?.initialAnimal ?? new Dog({ color: 'white', breed: 'samoyed' });
    this.doStuff(settings?.simulationSteps ?? 100);
  }

  // Issue3: Unable to set "get animal<T>": An accessor cannot have type parameters.
  get animal(): Animal<T> {
    return this._animal;
  }

  setAnimal<T>(animal: Animal<T>, settings: Omit<SimulatorSettings<T>, 'initialAnimal'>) {
    this._animal = animal;
    this.doStuff(settings.simulationSteps);
  }

  private doStuff(steps: number) {}
}

Here is the link to the Typescript Playground with the full code.

My question is: Is this possible to do (I assume it is) and how to do it without defining that T = DogProps | CatProps because users can create new implementations which should be supported?

interface BirdProps {
  wingSpan: number;
}

class Bird implements Animal<BirdProps> {
  constructor(public readonly props: BirdProps) {}
  eat() {}
}

const simulator = new SimulatorTry1();
// Color exists because the default animal is dog
const color = simulator.animal.props.color;

// Now the animal is bird, so props are BirdProps
simulator.setAnimal(new Bird({ wingSpan: 20 }), { simulationSteps: 10 });
const span = simulator.animal.props.wingSpan;

CodePudding user response:

This is unfortunately not possible; TypeScript doesn't have a way to represent mutable types, where some operation produces a value of one type and then later the same operation produces a value of an arbitrarily different type. It can narrow the apparent type of a value by gaining more information about it via control flow analysis, but you're not trying to do only narrowing. Presumably you'd want to see this happen:

const simulator = new SimulatorTry1();
const c0 = simulator.animal.props.color; // string
simulator.setAnimal(new Bird({ wingSpan: 20 }), { simulationSteps: 10 });
const c1 = simulator.animal.props.color // undefined, or possibly compiler error

But if c0 is of type string then c1 really must be of a type assignable to string. It can't really be undefined.


Control flow analysis does sometimes reset the apparent type and therefore re-widen it, so you could imagine making a type like unknown and then doing a series of narrowings and resettings. But these resettings only happen upon explicit assignment like simulator.animal.props.color = undefined. You can't make this happen via a call to simulator.setAnimal(). In order to make simulator.setAnimal() change the apparent type of simulator.animal.props, it would have to be an assertion method... and these only narrow.

So we're stuck; this isn't possible. There is a suggestion at microsoft/TypeScript#41339 to support mutable types. It's currently marked as "Awaiting More Feedback", meaning they want to hear compelling use cases from the community before even thinking of implementing it. If you think this Simulator use case is important, you could go there and describe it and why the available workarounds aren't acceptable. But I don't know that it would help much.


The workarounds I can imagine here are to replace statefulness with immutability, at least at the type level. That is, the call to setAnimal() should create a new simulator, or it should at least look like it does. For example, here's a way using an assertion method, where calling setAnimal() essentially invalidates the current simulator, and you need to access its simulator property to get the "new" one, even though there really is only one at runtime:

class Simulator<T = DogProps> {
  private _animal: Animal<T>;

  constructor(settings?: Partial<SimulatorSettings<T>>);
  constructor(settings: SimulatorSettings<T>) {
    this._animal = settings?.initialAnimal ?? new Dog({ color: 'white', breed: 'samoyed' });
    this.doStuff(settings?.simulationSteps ?? 100);
  }

  get animal(): Animal<T> {
    return this._animal;
  }

  setAnimal<U>(animal: Animal<U>, 
    settings: Omit<SimulatorSettings<U>, 'initialAnimal'>
  ): asserts this is {
    animal: never;
    setAnimal: never;
    simulator: Simulator<U>;
  };
  setAnimal(animal: Animal<any>, settings: SimulatorSettings<any>) {
    this._animal = animal;
    this.doStuff(settings.simulationSteps);
    this.simulator = this;
  }
  simulator: unknown;


  private doStuff(steps: number) { }
}

And then this is how you'd use it:

const sim1: Simulator = new Simulator();
const color = sim1.animal.props.color.toUpperCase();
console.log(color) // WHITE

sim1.setAnimal(new Bird({ wingSpan: 20 }), { simulationSteps: 10 });
// now you have to abandon sim1

const sim2 = sim1.simulator;
// const sim2: Simulator<BirdProps>

const span = sim2.animal.props.wingSpan.toFixed(2);
console.log(span) // "20.00"

Or you can just spawn new simulators, so there's no invalidation:

class Simulator<T = DogProps> {
  private _animal: Animal<T>;

  constructor(settings?: Partial<SimulatorSettings<T>>);
  constructor(settings: SimulatorSettings<T>) {
    this._animal = settings?.initialAnimal ?? new Dog({ color: 'white', breed: 'samoyed' });
    this.doStuff(settings?.simulationSteps ?? 100);
  }

  get animal(): Animal<T> {
    return this._animal;
  }

  spawnSimulator<U>(animal: Animal<U>, 
    settings: Omit<SimulatorSettings<U>, 'initialAnimal'>): Simulator<U> {
    return new Simulator({ initialAnimal: animal, ...settings });
  }

  private doStuff(steps: number) { }
}

And this is how you'd use it:

const sim1 = new Simulator();
// const sim1: Simulator<DogProps>
const color = sim1.animal.props.color.toUpperCase();
console.log(color) // WHITE

const sim2 = sim1.spawnSimulator(new Bird({ wingSpan: 20 }), { simulationSteps: 10 });
// const sim2: Simulator<BirdProps>

const span = sim2.animal.props.wingSpan.toFixed(2);
console.log(span) // "20.00"

// you can still access sim1
console.log(sim1.animal.props.breed.toUpperCase()) // "SAMOYED"

In either case you're giving up on the idea of unsupported mutable types and instead using TypeScript's normal immutable types.

Playground link to code

  • Related