TypeScript, gerekli tüm bilgilere sahip olsa bile türü belirlemez


TypeScript türlerinde bu sorunla karşılaştım. Aynı arayüzdeki farklı bir özelliğe dayalı olarak bir özelliğin türünü belirlemek için bir koşullu tür kullanıyorum. Burada, FruitBasket içindeki slicer özelliği, type'a bağlı olarak ya AppleSlicer ya da BananaSlicer olur.

Fonksiyon test'e bakarsanız, bir FruitBasket örneği alıyor, bu örnek Apple veya Banana olabilir. Bu yüzden tipi, type'ın bunlardan birine eşit olup olmadığını kontrol ederek daraltıyorum, ama hala basket.slicer'ın deterministik olmadığına dair bir uyarı alıyorum. Oysa gerekli tüm bilgilere sahip olmalı ve bunun bir AppleSlicer olduğunu belirleyebilmeliydi. Bunu nasıl düzeltebilirim?

TypeScript 4.5.4 kullanıyorum.

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()
  }
}

Cevap #1

Buradaki büyük sorun, FruitBasket<Fruits>'in, nesnenin türünü daraltmak için bir ayrımcı özelliği (bu type olurdu) kontrol edebileceğiniz uygun bir ayrılmış birlik olmamasıdır. Sadece ayrılmış bir birlik olmamakla kalmaz, hatta hiç birlik bile değildir. Bununla eşdeğerdir

{ type: Fruits, slicer: AppleSlicer | BananaSlicer }

İşte bu tanıma göre tamamen geçerli bir FruitBasket<Fruits> burada:

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

Sadece type Fruits.Apple olduğu için, slicer'ın bir FruitSlicer<Fruits.Apple> olacağı anlamına gelmez. Yani eğer test() bir FruitBasket<Fruits> kabul ediyorsa, o zaman whoops'u kabul eder:

test(whoops) // no error here either

Ve bu, test() uygulamasının gerçekten basket.type'a bakarak basket.slicer hakkında güvenli bir şekilde herhangi bir şeyi kesin olarak söyleyemeyeceği anlamına gelir. Derleyici hatası geçerlidir. Eyvah.


Yani test'in FruitBasket<Fruits>'i kabul etmesini istemiyorsunuz. İstediğiniz şey, ayrılmış bir birleşim olan ve type'ın ayrımcı özellik olduğu FruitBasket<Fruits.Apple> | FruitBasket<Fruits.Banana> birleşim tipi.

Eğer bunu elle yazmak istemiyorsanız (örneğin, enum’unuzda başka birçok Fruits varsa), bu birleşimi kendi FruitBasket<T> sürümünüzden şu şekilde oluşturabilirsiniz:

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

Burada, Fruits içindeki her F için bir FruitBasket<F> özellik türü ile bir eşlenmiş tür oluşturuyoruz ve ardından istenen birleşimi elde etmek için hemen Fruits ile bu eşlenmiş türden indeks alıyoruz.

Artık bunu test() için parametre türü yapabilir ve basket.type === Fruits.Apple kontrolünün gerçekten basket türünü FruitBasket<Fruits.Apple> olarak daralttığını görebiliriz:

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

Artık test() uygulaması hiçbir hata vermeden derleniyor. Bu, artık test(whoops) çağrılamayacağı anlamına gelmeli:

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

Yani derleyici whoops ifadesini doğru şekilde reddediyor. Şimdi FruitBasket<Fruits.Apple> ve FruitBasket<Fruits.Banana> ifadelerini kabul ettiğinden emin olalım:

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

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

Güzel görünüyor.

Koda yönelik oyun alanı bağlantısı

Cevap #2

Benim tercih ettiğim desen, dilimleyicileri ortak bir desene uyarlamak olurdu, böylece böyle bir kontrol yapmanıza gerek kalmazdı. (Birkaç dakika içinde güncelleyeceğim) Ancak sorunuza cevap, türü doğru bir şekilde kontrol etmek için açık bir tip koruyucuya sahip olmaktır ve bu, tip sistemine gerçekten belirli bir tür olma koşullarınızı karşıladığını doğrulama şeklinde geri döner.

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;
}

GÜNCELLEME

Mevcut kodunuzu dönüştürmenin düşündüğüm kadar kolay olmadığı ortaya çıktı, çünkü bir enum'dan türetmek bir sınıf veya arayüz gibi çalışmıyor. İşte bir miktar yorumla güncellenmiş örneğim. Sınıfları mı yoksa tiplerle düz JavaScript nesnelerini mi kullanmak istediğiniz size kalmış. Ben sadece instanceof kullanabilme yeteneği için sınıfları tercih ediyorum.

Bu, bahsettiğim adaptör desenini (arayüzünüzü/sınıfınızı başka biriyle sarmalayarak aynı özellik kümesine sahip olmalarını sağlama) mutlaka göstermez, ancak eğer yaparsanız ne olacağını gösterir (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!`);
})