簡體   English   中英

基於 TypeScript 中聯合子集的類型

[英]Typing based on subsets of unions in TypeScript

我正在開發一個 TypeScript 庫(這里是),用於代數數據類型(或任何人想稱呼它們的東西),並且正在努力處理一些更復雜的類型。

代數數據類型的工作方式如下:

// Create ADT instantiator
const value_adt = adt({
    num: (value: number) => value,
    str: (value: string) => value,
    obj: (value: object) => value,
});

// Extract union of variants
type ValueADT = Variants<typeof value_adt>;

// 'ValueADT' will be typed as:
// { [tag]: "num", value: number } |
// { [tag]: "str", value: string } |
// { [tag]: "obj", value: object }

// Create instance of 'value_adt'
const my_value = value_adt.str("hello") as ValueADT;

我創建了一個match項 function(是的,這在很大程度上受到了 Rust 的啟發)用於匹配,並從匹配的變體中提取相關數據,其工作方式如下:

match(my_value, {
    num: value => console.log(`number: ${value}`);
    str: value => console.log(`string: ${value}`);
    obj: value => console.log(`object: ${value}`);
});

提供的匹配器的參數類型非常有用。 但是,我添加了省略一個或多個匹配器並將它們替換為默認大小寫的選項:

match(my_value, {
    num: value => console.log(`number: ${value}`);
    [def]: value => console.log(`not number: ${value}`);
});

我設法將默認匹配器的參數鍵入為所有變體值類型的聯合(在本例中為ValueADT["value"] ),但只有在所有匹配器都被省略並替換為默認匹配器時才真正有意義。

我非常希望能夠根據提供的其他匹配器鍵入默認匹配器的值參數,例如它應該是string | object 上例中的string | object ,不是number | string | object number | string | object number | string | object ., 所以我的問題是什么(如果有的話)先進的 TypeScript 巫術可以實現這樣的目標?

matcher源代碼和類型的鏈接。

筆記:
我不確定這個問題的標題,歡迎提出建議。

更新:
這是一些說明不良行為的自包含代碼,即默認匹配器的參數類型是一個包含number的聯合,這在邏輯上是不可能的:

type Variant<T, U> = { tag: T, value: U };

const adt = { tag: "num", value: 1 } as
    | Variant<"num", number>
    | Variant<"str", string>
    | Variant<"obj", object>;

type MatchAll<T extends Variant<string, any>> = {
    [K in T["tag"]]: T extends Variant<K, infer V>
        ? (value: V) => any
        : never
};

const def = Symbol("[def]ault matcher");

type Matchers<T extends Variant<string, any>> =
    | MatchAll<T>
    | Partial<MatchAll<T>> & {
        [def]: (value: T["value"]) => any;
    };

function match<
    T extends Variant<string, any>,
    U extends Matchers<T>,
>(
    variant: T,
    matchers: U,
) {
    const matcher = matchers[variant.tag as keyof U];

    if (matcher === undefined) {
        throw new Error(`No match for '${variant.tag}'!`);
    }

    matcher(variant.value);
}

match(adt, {
    num: value => console.log(`number: ${value}`),
    [def]: value => console.log(`not number: ${value}`),
});

...和游樂場鏈接。

更新 2:
在回答這個問題之后,我還設法正確地推斷出match的返回類型(它從被調用的匹配器返回返回值)。 關於我在這里提出的問題,這是一個旁白,但對於將來遇到此問題的任何人來說可能會很有趣: 游樂場

在接下來的內容中,我只關注類型,並且只關注match()調用者的觀點; 實現可能需要類型斷言等來防止編譯器錯誤,因為復雜的通用調用簽名往往難以驗證。


我在這里傾向於重載match()以便完全詳盡的“全部匹配”情況與默認匹配器“部分”情況分開處理。

對於“部分”情況,我們希望match()不僅在variant參數的聯合類型T中是通用的,而且在matchers參數的鍵K中也是通用的。 我們也可以讓TK中的Matchers都通用,這樣我們就可以盡可能精確地表示所有方法的參數類型。


所以這是Matchers<T, K>的可能定義:

type Matchers<T extends Variant<string, any>, K extends PropertyKey> = {
    [P in K]: (value: P extends typeof def ?
        Exclude<T, { tag: Exclude<K, typeof def> }>["value"] :
        Extract<T, { tag: P }>["value"]
    ) => any };

這個想法是我們map遍歷K的每個元素P以獲得返回any的回調。 使用鍵P查找回調的value參數類型:如果Pdef的類型,則value對應於K中未提及的所有變體(我們使用Exclude<T, U>實用程序類型構建)。 否則,我們假設P是來自T的標簽之一,在這種情況下, value對應於帶有該標簽的變體的值(我們使用Extract<T, U>實用程序類型構建)。

請注意,如果K恰好是T["tag"]的完整並集,則[def]回調將具有類型為nevervalue參數。 這可能有點奇怪,但可能無害。 如果真的很重要,您可以更改此類型,使整個屬性的類型為never而不僅僅是回調參數。


這是我們重載的調用簽名:

declare function match<T extends Variant<string, any>, K extends T["tag"] | typeof def>(
    variant: T, matchers: Matchers<T, K | typeof def>
): void;

declare function match<T extends Variant<string, any>>(
    variant: T, matchers: Matchers<T, T["tag"]>
): void;

第一個簽名使用def屬性處理“部分”情況。 這里的推論有點棘手; 我發現我需要指定| typeof def | typeof defK約束Matchers的第二個參數中。 否則,編譯器將傾向於“放棄”從matchers參數的實際鍵中推斷出K

第二個簽名處理沒有def屬性的“全部匹配”情況,並且不需要在K中是通用的(因為它總是只是完整的T["tag"]聯合)。


讓我們對其進行測試:

declare const adt:
    | Variant<"num", number>
    | Variant<"str", string>
    | Variant<"dat", Date>;

首先,讓我們嘗試“全部匹配”的情況:

match(adt, {
    num: v => v.toFixed(),
    dat: v => v.toISOString(),
    str: v => v.toUpperCase()
});

看起來挺好的。 編譯器知道每個回調中v的類型。 現在讓我們省略str鍵,並添加[def]鍵:

match(adt, {
    num: v => v.toFixed(),
    dat: v => v.toISOString(),
    [def]: v => v.toUpperCase()
});

看起來也不錯; 編譯器知道v在默認匹配器中必須是string 現在讓我們也省去dat鍵:

match(adt, {
    num: v => v.toFixed(),
    [def]: v => typeof v === "object" ? v.toISOString() :
        v.toUpperCase()
});

還好; v的類型現在是Date | string Date | string 最后讓我們只使用默認匹配器:

match(adt, {
    [def]: v => typeof v === "number" ? v.toFixed() :
        typeof v === "object" ? v.toISOString() :
            v.toUpperCase()
})

v的類型是全數number | Date | string number | Date | string number | Date | string聯合。 好的,現在讓我們嘗試包括所有標簽默認匹配器:

const assertNever = (x: never): never => { throw new Error("Uh oh") };

match(adt, {
    num: v => v.toFixed(),
    dat: v => v.toISOString(),
    str: v => v.toUpperCase(),
    [def]: v => assertNever(v)
});

我想這也很好。 接受默認匹配器,並且v的類型為never (因為我們從不期望調用默認匹配器)。

讓我們犯錯誤,看看它說了什么。 如果我們省略str和默認匹配器,我們會得到一個錯誤:

match(adt, {
    num: v => v.toFixed(),
    dat: v => v.toISOString(),
}); // error!
// No overload matches this call.
// Overload 1: Property '[def]' is missing
// Overload 2: Property 'str' is missing

該錯誤描述了我們如何未能正確調用兩個調用簽名中的任何一個,並且缺少[def]str 如果我們拼錯了其中一個鍵:

// oops
match(adt, {
    num: v => v.toFixed(),
    dat: v => v.toISOString(),
    strr: v => v.toUpperCase(), // error, 
    //strr does not exist, Did you mean to write 'str'?
})

該錯誤描述了它如何不希望看到拼寫錯誤的鍵,並且(取決於我們的拼寫有多接近)建議修復。


所以你 go,通過添加匹配器鍵中通用的第二個調用簽名,我們可以獲得你正在尋找的默認回調參數的推理類型。

游樂場代碼鏈接

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM