網站搜尋

列舉型別 - 管理 TypeScript 中的判別聯合


列舉型別無法工作

TypeScript 枚舉接近我所需要的,但它們仍然不完全符合。我需要一組 在編譯時已知 且 在運行時靜態 的項目,它們的行為像枚舉,但同時是類的實例,以便能夠添加屬性或方法等功能。

為了這個問題,假設使用 TypeScript 4.4.3。

例子

舉例來說,假設一間小型便利店有三個走道。每個走道都有一個 id 和一個 name:

// Aisle objects
name: 'food', aisleId: '4'
name: 'housewares', aisldId: '7'
name: 'camping', aisleId: '9'

當巷道的 name 或 aisleId 被包含在變數中時,使用 Aisles 類別的一些靜態輔助方法是有意義的:

static getByName(name: string): Aisle {
  // return the right Aisle, or return undefined, or maybe throw
}

static getById(aisleId: string): Aisle {
  // same, but for aisleId
}

(可能會有 description 屬性,還有其他屬性,但不會對 Aisle 物件進行按 description 的查找。)

如果某些程式碼想要操作特定的 Aisle,能夠直接寫 Aisles.Food 或 Aisles['7'] 會很方便。雖然上述的 getBy 方法可以使用,但當 'food' 在編譯時已知時,要使用 Aisles.getById('food') 並不是很理想。如果這些必須放在不同的列舉中,例如 AisleNames.Food 或 AisleIds['7'],那也是可以的。

目標

能達成所有或大部分這些目標將會非常好:

  • 僅適用於傳入的 Aisle 實例的程式碼應該能夠依賴環境宣告,而不需要匯入任何東西:function cleanAisle(aisle: Aisle) {} 不需要匯入任何東西,因為它並沒有建立任何實例。
  • 可以在某處公開一個類似列舉的所有走道的集合:也許是 AisleList。或者 Aisles。或 AislesEnum。
  • 環境型別聲明在整個代碼庫中典型地驅動型別安全。需要兩個可能衝突的型別/類信息來源並不是很好,一個 .ts 文件公開了一個枚舉或類似枚舉的對象,另一個在環境 .d.ts 文件中影射該枚舉,兩者都不是單一的標準來源。
  • 如果有任何不一致,某處應該會發生類型錯誤:如果任何程式碼新增了通道、刪除了通道、命名錯誤、拼寫錯誤,或者有任何不應該出現的重複情況。
  • 這個方案的程式碼重複量已降到最低。例如,將屬性命名後,卻又將相同的名稱以字串形式傳入構造函數,僅為了讓類別實例可以返回它自己的名稱,這樣不好,例如 readonly food=new Aisle('food', '4') 重複了 'food'。而且這可能會導致屬性與實例之間的不一致。理想情況應該是 new Aisle<'food'>(),然後在任何地方使用實際的 name 屬性或名稱作為程式碼符號,從環境型別宣告或類別的正確實例中導出。(有一個 TypeScript 插件可以在編譯時實現 nameof,可能會有幫助,但我還沒做到那一步。)

我的嘗試

在我嘗試達成這些目標的過程中,出現了幾個問題。(你可以在 TypeScript Playground 中看到我一些的摸索,但並非我嘗試的所有內容都在那裡。)

我試著在 Aisle 上添加一個通用型別參數,例如 Aisle<'food'>,但是在將它們放入集合時,我該如何使用接口呢?例如,給定 interface Aisle<T extends AisleName> {} 和一個具體類 ConcreteAisle<T extends AisleName> implements Aisle<T> {},無法將 ConcreteAisle<T> 的實例放入 Aisle[],因為 Aisle 需要一個通用型別參數,但沒有單一的通用型別可以給出。這裡使用聯合型別會有用嗎,例如 Aisle<AisleName>,其中 type AisleName='food' | 'housewares' | 'camping'?然後唯一性/完整性問題又出現了。Set<Aisle> 無法保證唯一性。這就是「TypeScript 中的枚舉不好用」的情況出現的地方。

`enum Aisle as const` 可能會有幫助,如果代碼不需要在 `tsconfig.json` 中設置 `"isolatedModules": true`。所以那不是一個選項。

在方法 getAisleById() 和 getAisleByName() 上也遇到了一些問題。使用一個 Aisle 對象的 Set,並對其進行 .map() 以生成 Map<AisleName, Aisle> 以及針對 aisleId 的 Map 時,遇到的問題是即使對傳入的值使用了類型聯合,Map<K, V>#get 方法仍然會返回類型 V | undefined,這需要某種變通方法來欺騙 TypeScript,相信只要值屬於聯合類型,結果就不會是 undefined。我知道如何使用返回 var is Type 或 asserts var is Type 的函數,但希望不要將它們暴露給這些對象的使用者——如果可能的話,這些應該全部只在 Aisles 和 Aisle 類 enum 類型結構的實現內部使用。

如果有必要,我可以為每個 Aisle 寫一個單獨的具體類。這樣也沒問題,並且可以讓我在每個類中硬編碼 aisleName 和 aisleId,而不需要將這些值傳入建構函數。但是,"如何在作為判別聯合的集合中使用這些類" 的問題會變得更嚴重。

有人有任何想法嗎?

答案#1

如你所指出的,TypeScript 中的 enum 值實際上只是原始的字串或數字。它們不像 Java 中的 enum 那樣運作,Java 的 enum 底層只是類別實例。如果你想要類似 enum 的類別實例,你必須自己構建它們。

一種方法可能是不導出基礎類(這樣你無法控制的新實例就不會到處出現),並確保它們具有足夠強的類型,以便生成的枚舉可以通過具有字面值判別屬性的方式行為如判別聯合類型。看起來你有兩個這樣的屬性,name 和 aisleId,所以我們將這個基礎 Aisle 類在這兩個屬性上做成泛型:

class Aisle<N extends string, I extends string> {
    constructor(public name: N, public aisleId: I, public description: string) { }
    someMethod() {
        console.log("Hi, I'm " + this.name + " and my Id is " + 
          this.aisleId + " and my description is " + this.description);
    }
}

由於我們希望匯出的 Aisles 類枚舉對象擁有每個 name 和 aisleId 值的鍵,我們可以編寫一個輔助函數,該函數返回一個包含這兩個鍵的對象,每個值都是相關的類實例:

function make<N extends string, I extends string>(name: N, aisleId: I, description: string) {
    const aisle = new Aisle(name, aisleId, description);
    return { [name]: aisle, [aisleId]: aisle } as Record<N | I, typeof aisle>;
}

請注意,返回類型必須被斷言為 Record<N | I, typeof aisle>,因為編譯器不幸地會將計算出的屬性鍵類型擴展為 string;詳見 microsoft/TypeScript#13948。

現在我們可以使用物件展開來建立我們匯出的 Aisles 枚舉物件:

export const Aisles = {
    ...make("food", "4", 'All things edible'),
    ...make("housewares", "7", 'Make your home zing'),
    ...make("camping", "9", 'Get into the outdoors!')
} as const;

你可以檢查 Aisles 來確認它包含你關心的所有鍵和值。

/* const Aisles: {
    readonly camping: Aisle<"camping", "9">;
    readonly 9: Aisle<"camping", "9">;
    readonly housewares: Aisle<"housewares", "7">;
    readonly 7: Aisle<"housewares", "7">;
    readonly food: Aisle<...>;
    readonly 4: Aisle<...>;
} */

如果你希望 Aisles 也成為對應於 Aisles 對象值的類型(enum 會自動做到這一點),你可以這麼做:

export type Aisles = typeof Aisles[keyof typeof Aisles];

如果你想要為 Aisles 附加更多類型,你可以透過 namespace 來實現;例如,對應於相關鍵聯集的 Name 和 Id 類型:

namespace Aisles {
    export type Name = Aisles extends infer A ? A extends Aisle<infer N, any> ? N : never : never;
    export type Id = Aisles extends infer A ? A extends Aisle<any, infer I> ? I : never : never;
}

export default Aisles

希望這已經包含了你關心的大部分功能。你可以訪問 Aisles 的 name 和 aisleId 鍵:

Aisles.food.someMethod(); 
// "Hi, I'm food and my Id is 4 and my description is All things edible"
Aisles[7].someMethod(); 
// "Hi, I'm housewares and my Id is 7 and my description is Make your home zing"

你可以將 Aisles 類型用作區分聯合型別:

function processAisle(aisle: Aisles) {
    switch (aisle.name) {
        case "camping": return 0;
        case "food": return 1;
        // not all paths return a value unless you uncomment next line
        /* case "housewares": return 2; */
    }
}

您可以使用匯出的 Aisles.Name 類型將鍵限制為僅 name,而不是 aisleId 屬性:

function reImplementGetAisleByName<N extends Aisles.Name>(name: N) {
    return Aisles[name]
}
const camping = reImplementGetAisleByName("camping");
// const camping: Aisle<"camping", "9">

程式碼的 Playground 連結

答案#2

要能對一個類型聯合進行區分,這些類型之間必須至少有一個具有字面值(布林值、字串、數字或唯一符號)的共同屬性。你在 Aisle 上添加 T extends ... 屬性是正確的方向。

這是一種可能的方法,可以從你的 Aisle 類中提取 id 和 name 參數,以實現最大的編譯時可推斷性:

// `id` and `name` will both be pulled out as constants here, allowing you to get
// their exact values later on.
class Aisle<Id extends number, N extends string> {
  constructor(readonly id: Id, readonly name: N) { }
}

// Here's an "enum" of statically known Aisles
const Aisles = {
  1: new Aisle(1, 'baking'),
  2: new Aisle(2, 'garden'),
  3: new Aisle(3, 'spices'),
  4: new Aisle(4, 'food'),
  5: new Aisle(5, 'toys'),
  6: new Aisle(6, 'cleaning'),
  7: new Aisle(7, 'housewares'),
  8: new Aisle(8, 'travel'),
  9: new Aisle(9, 'camping'),
} as const; // Don't forget this

// And here's a type that makes referencing specific Aisles easier
type Aisles = typeof Aisles;

// Like so
type AisleOneOrTwo = Aisles[1] | Aisles[2];

// Accessing a particular Aisle
const a0 = Aisles[1] // Aisle<1, "baking">

// Fields are resolved to their literal values here.
a0.id // 1
a0.name // "baking"

// Here's a full union type of your Aisles.
type AislesUnion = Aisles[keyof Aisles];

這裡也有一個通往遊樂場的連結。