TypeScript: How to constrain a generic method's return type to depend on argument type?

Viewed 50

How should the return type of getStreamFor$(item: Item) be constrained to depend on method parameter type Item?

What I need is:

  • when I call getStream$(Item.Car) to have the type of stream$ as Observable<CarModel>
  • when I call getStream$(Item.Animal), to have the type of stream$ as Observable<AnimalModel>

Now the type of stream$ is Observable<CarModel | AnimalModel | TemperatureModel>.

(see the place commented 'HERE', StackBlitz)

enum Item {
  CAR = 'car',
  ANIMAL = 'animal',
  TEMPERATURE = 'temperature'
}

interface CarModel {
  make: string;
}

interface AnimalModel {
  breed: string;
}

interface TemperatureModel {
  tmp: number;
  scale: string;
}

class Channel {
  stream: {
    [prop in Item]: Observable<CarModel | AnimalModel | TemperatureModel>
  };

  constructor() {
    this.stream = {
      [Item.CAR]: of({ make: 'Ford' }) as Observable<CarModel>,
      [Item.ANIMAL]: of({ breed: 'Cat' }) as Observable<AnimalModel>,
      [Item.TEMPERATURE]: of({ tmp: 35, scale: 'Celsius' }) as Observable<TemperatureModel>
    };
  }

  getStreamFor$(item: Item): Observable<CarModel | AnimalModel | TemperatureModel> {
    return this.stream[item];
  }
}

const c = new Channel();
const stream$ = c.getStreamFor$(Item.CAR) // <--- HERE
  .pipe(
    map((value) => {
      value.make // <--- '.make' is marked as error by IDE
    }),
  )
1 Answers

By annotating stream as { [K in Item]: Observable<CarModel | AnimalModel | TemperatureModel> }, you've made the compiler forget about the particular mapping between each key from Item and each model. Similarly, by annotating the return type of getStreamFor$() as Observable<CarModel | AnimalModel | TemperatureModel>, that function can do nothing but return the wide union type that you're having problems with. If you want the return type of getStreamFor$(item) to depend on the the type of item, then you need to make it a generic method (or else make it an overload, but I wouldn't recommend that).


The easiest way to proceed here to get the typing you'd like is to remove these annotations and let the compiler infer the types. For stream that means you need to use a class field initializer instead of a declaration:

class Channel {
  stream = {
    [Item.CAR]: of({ make: 'Ford' }) as Observable<CarModel>,
    [Item.ANIMAL]: of({ breed: 'Cat' }) as Observable<AnimalModel>,
    [Item.TEMPERATURE]: of({ tmp: 35, scale: 'Celsius' }) as Observable<
      TemperatureModel
    >
  };

  constructor() {}

And then just make getStreamFor$() generic in type parameter I which is constrained to Item, where I is the type of the passed-in item parameter:

  getStreamFor$<I extends Item>(item: I) {
    return this.stream[item];
  }
}

Now everything will just work:

const c = new Channel();
const stream$ = c.getStreamFor$(Item.CAR).pipe(
  map(value => {
    value.make; // okay now
  })
);

You can stop there if you want.


Now, if you use IntelliSense you can see the inferred types of stream and getStreamFor$:

/* (property) Channel.stream: {
    car: Observable<CarModel>;
    animal: Observable<AnimalModel>;
    temperature: Observable<TemperatureModel>;
}

(method) Channel.getStreamFor$<I extends Item>(item: I): {
    car: Observable<CarModel>;
    animal: Observable<AnimalModel>;
    temperature: Observable<TemperatureModel>;
}[I] */

Note how the that return type is expressed in terms of stream's type. So if you want to annotate things, you should probably give names to stream's type and use it to annotate:

interface ItemModelMap {
  [Item.CAR]: Observable<CarModel>;
  [Item.ANIMAL]: Observable<AnimalModel>;
  [Item.TEMPERATURE]: Observable<TemperatureModel>;
}

class Channel {
  stream: ItemModelMap;
  constructor() {
    this.stream = {
      [Item.CAR]: of({ make: 'Ford' }),
      [Item.ANIMAL]: of({ breed: 'Cat' }),
      [Item.TEMPERATURE]: of({ tmp: 35, scale: 'Celsius' })
    };
  }

  getStreamFor$<I extends Item>(item: I): ItemModelMap[I] {
    return this.stream[item];
  }
}  

Since stream is annotated as ItemModelMap, you don't need to use as Observable<CarModel> etc. in its initializer. And you can move the initialization back inside the constructor() method if you want. And finally the return type of getStreamFor$() can be expressed fairly simply as ItemModelMap[I].

Everything still works how you want, and now you have strong type annotations.

Stackblitz link to code

Related