Home > Mobile >  TypeScript does not determine the type even if it has all necessary information
TypeScript does not determine the type even if it has all necessary information

Time:01-13

I came across this issue in TypeScript types. I'm using a conditional type based on a different property in the same interface to determine the type of one property. Here, the property slicer in FruitBasket is either AppleSlicer or BananaSlicer depending on the type.

If you look at the function test, it receives a instance of FruitBasket, which can be either Apple or Banana. So, I narrow down the type by checking that the type is equal to one of them, but it still complains that basket.slicer is not deterministic. But, it should have all information it needs to determine that it's an AppleSlicer.How can I fix this?

I'm using TypeScript 4.5.4.

enum Fruits {
  Apple = "Apple",
  Banana = "Banana",
}

// These interfaces can have completely different shapes as shown.
interface AppleSlicer { apple(): void }
interface BananaSlicer { banana(): void }

export type FruitSlicer<TType> = TType extends typeof Fruits.Apple
  ? AppleSlicer
  : TType extends typeof Fruits.Banana
  ? BananaSlicer
  : never

export interface FruitBasket<TType extends Fruits> {
  type: TType
  slicer: FruitSlicer<TType>
}

const test = (basket: FruitBasket<Fruits>) => {
  if (basket.type === Fruits.Apple) {
   // This gives me a compile error because basket.slicer is either AppleSlicer or BananaSlicer.
   // But, it should have all information it needs to deduce that it can only be an AppleSlicer
    basket.slicer.apple()
  }
}

CodePudding user response:

The big problem here is that FruitBasket<Fruits> is not a proper discriminated union where you can check a discriminant property (which would be type) to narrow the type of the object. Not only isn't it a discriminated union, it's not even a union at all. It's equivalent to

{ type: Fruits, slicer: AppleSlicer | BananaSlicer }

So here is a completely valid FruitBasket<Fruits> according to that definition:

const whoops: FruitBasket<Fruits> = {
  type: Fruits.Apple,
  slicer: { banana() { } }
} // okay

Just because type is Fruits.Apple, it doesn't mean that slicer will be a FruitSlicer<Fruits.Apple>. So if test() accepts a FruitBasket<Fruits>, then it accepts whoops:

test(whoops) // no error here either

And that means the implementation of test() really cannot safely conclude anything in particular about basket.slicer by looking at basket.type. The compiler error is a valid one. Whoops.


So you don't want test to accept FruitBasket<Fruits>. What you want is the union type FruitBasket<Fruits.Apple> | FruitBasket<Fruts.Banana>, a discriminated union where type is the discriminant property.

If you don't want to write that type out manually (e.g., there are lots of other Fruits in your enum), you can generate this union from your version of FruitBasket<T> as follows:

type FruitBasketUnion = { [F in Fruits]: FruitBasket<F> }[Fruits]
// type FruitBasketUnion = FruitBasket<Fruits.Apple> | FruitBasket<Fruits.Banana>

Here we are creating a mapped type with a FruitBasket<F> property type for each F in Fruits, and then immediately indexing into that mapped type with Fruits to get the desired union.

Now we can make that the parameter type for test() and see that the check basket.type === Fruits.Apple does narrow the type of basket to FruitBasket<Fruits.Apple>:

const test = (basket: FruitBasketUnion) => {
  if (basket.type === Fruits.Apple) {
    basket.slicer.apple() // okay
  }
}

Now the test() implementation compiles with no error. That had better mean you can't call test(whoops) anymore:

test(whoops); // error!
//   ~~~~~~
// Argument of type 'FruitBasket<Fruits>' is not assignable to
// parameter of type 'FruitBasketUnion'.

So the compiler correctly rejects whoops. Let's make sure it accepts FruitBasket<Fruits.Apple> and FruitBasket<Fruits.Banana>:

test({
  type: Fruits.Banana,
  slicer: { banana() { } }
}); // okay

test({
  type: Fruits.Apple,
  slicer: { apple() { } }
}); // okay

Looks good.

Playground link to code

CodePudding user response:

My preferred pattern would be to adapt the slicers to a common pattern, where you wouldn't have to do such a check. (I'll update in a few minutes) The answer to your question, though, is to have an explicit typeguard to check the type properly, and this would return confirmation to the type system that it indeed matches your conditions for being a specific type.

enum Fruits {
  Apple = "Apple",
  Banana = "Banana",
}

// These interfaces can have completely different shapes as shown.
interface AppleSlicer { apple(): void }
interface BananaSlicer { banana(): void }

export type FruitSlicer<TType> = TType extends typeof Fruits.Apple
  ? AppleSlicer
  : TType extends typeof Fruits.Banana
  ? BananaSlicer
  : never

export interface FruitBasket<TType extends Fruits> {
  type: TType
  slicer: FruitSlicer<TType>
}

const test = (basket: FruitBasket<Fruits>) => {
  if (isAppleBasket(basket)) {
   // This gives me a compile error because basket.slicer is either AppleSlicer or BananaSlicer.
   // But, it should have all information it needs to deduce that it can only be an AppleSlicer
    basket.slicer.apple()
  }
  else if (isBananaBasket(basket)) {
    basket.slicer.banana()
  }
}

function isAppleBasket(candidate: FruitBasket<Fruits>): candidate is FruitBasket<Fruits.Apple> {
    return candidate.type === Fruits.Apple;
}
function isBananaBasket(candidate: FruitBasket<Fruits>): candidate is FruitBasket<Fruits.Banana> {
    return candidate.type === Fruits.Banana;
}

UPDATE

So it turns out that converting your existing code was not as easy as I thought, because extending from an enum doesn't work the same as a class or interface. Here is my updated example, with some interpretation. It's up to you if you want to use classes or plain javascript objects with types. I prefer classes if only for the ability to use instanceof.

This doesn't necessarily show you the adaptor pattern I mentioned (wrap your interface/class with another to make them have the same featureset), but it shows what would happen if you did (FruitSlicer<T extends Fruit>)

enum Fruits {
  Apple = "Apple",
  Banana = "Banana",
}

interface Fruit {
    fruitType: Fruits;
}

class Apple implements Fruit {
    public fruitType: Fruits = Fruits.Apple;
}
class Banana implements Fruit {
    public fruitType: Fruits = Fruits.Banana;
}

interface SlicedFruit {
    fruitType: Fruits;
    numberOfSlices: number;
}
interface FruitSlicer<T extends Fruit> {
    fruitType: Fruits;
    canHandleFruit(fruit: Fruit): boolean;
    slice(fruit: T): SlicedFruit;
}

class AppleSlicer implements FruitSlicer<Apple> { 
    public fruitType = Fruits.Apple;

    public canHandleFruit(fruit: Fruit): boolean {
        return fruit.fruitType === this.fruitType;
    }

    public slice(fruit: Apple): SlicedFruit{
        return {
            // if we turn the number of slices into a property,
            // we could make an abstract base class that does this work for us
            // for any future fruit.
            fruitType: fruit.fruitType,
            numberOfSlices: 8
        }
    }
}

class BananaSlicer implements FruitSlicer<Banana> { 
    public fruitType = Fruits.Banana;

    public canHandleFruit(fruit: Fruit): boolean {
        return fruit.fruitType === this.fruitType;
    }

    public slice(fruit: Banana): SlicedFruit {
        return {
            fruitType: Fruits.Banana,
            numberOfSlices: 15
        }
    }
}

const allSlicers: FruitSlicer<Fruit>[] = [ new AppleSlicer(), new BananaSlicer() ]

// in terms of objects, a basket can hold several types of fruit. We could make a basket of
// all bananas or all apples, but it doesn't make sense to have ONLY that option.
// this example has a full basket of any fruit type, and we decide for each fruit
//  what type of slicer to use later on.
const basket: Fruit[] = [ new Apple(), new Banana(), new Banana(), new Apple()];

basket.forEach(f => {
    // allSlicers.find could make use of the factory pattern, 
    //      and could be more performant with a map lookup, etc.
    const slicedResult: SlicedFruit | undefined = allSlicers.find(s => s.canHandleFruit(f))?.slice(f);
    console.log(!slicedResult ? 'null' : `${slicedResult?.fruitType} sliced ${ slicedResult.numberOfSlices } ways!`);
})

CodePudding user response:

You can write a type predicates function to check the type of basket before using the basket.

enum Fruits {
  Apple = "Apple",
  Banana = "Banana",
}

// These interfaces can have completely different shapes as shown.
interface AppleSlicer { apple(): void }
interface BananaSlicer { banana(): void }

export type FruitSlicer<TType> = TType extends typeof Fruits.Apple
  ? AppleSlicer
  : TType extends typeof Fruits.Banana
  ? BananaSlicer
  : never

export interface FruitBasket<TType extends Fruits> {
  type: TType
  slicer: FruitSlicer<TType>
}

function checkTypeOfBasket<T extends Fruits>(basket: FruitBasket<Fruits>, type: T): basket is FruitBasket<T> {
  return basket.type === type
}

const test = (basket: FruitBasket<Fruits>) => {
  if (checkTypeOfBasket(basket, Fruits.Apple)) {
    // after checking, typescript understands the basket is an apple basket
    basket.slicer.apple()
  }
}
  •  Tags:  
  • Related