3

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;
4
  • 2
    This is unfortunately not possible; TypeScript does not model arbitrary mutation of types. If sim.animal.props.color is a string at some point, then you can't have it become something other than a string (like undefined) after a call to sim.setAnimal(new Bird({wingspan:123})). See ms/TS#41339 for a suggestion to support this sort of thing. This means the whole point with T is moot. Do you want me to write up an answer saying this is impossible and why? Or am I missing something about the question? Commented Aug 20, 2022 at 20:13
  • Thanks a lot! You are not missing anything, you got it right and you named it better than me, as a mutation of a type that has a generic T (used as the props). I've lost so many hours on this trying to make it work :D Regarding the answer, please do. I will mark it as an accepted answer! Commented Aug 20, 2022 at 20:51
  • I just changed all the generics in SimulatorTry2 to be <any> and the code seems to be working: Code Sandbox Link. Don't know if this is what you are looking for. Commented Aug 20, 2022 at 22:12
  • @shadow-lad True, it works with any, but I wanted to somehow use T so I can have support from Typescript whenever I type simulator.animal.props to show me what is valid there instead of any. Commented Aug 21, 2022 at 21:20

1 Answer 1

1

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

Sign up to request clarification or add additional context in comments.

1 Comment

Thanks a lot @jcalz! I really like your second workaround that returns a new class - it has cleaner usage compared to the first one with the class property simulator.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.