[英]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
中也是通用的。 我們也可以讓T
和K
中的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
參數類型:如果P
是def
的類型,則value
對應於K
中未提及的所有變體(我們使用Exclude<T, U>
實用程序類型構建)。 否則,我們假設P
是來自T
的標簽之一,在這種情況下, value
對應於帶有該標簽的變體的值(我們使用Extract<T, U>
實用程序類型構建)。
請注意,如果K
恰好是T["tag"]
的完整並集,則[def]
回調將具有類型為never
的value
參數。 這可能有點奇怪,但可能無害。 如果真的很重要,您可以更改此類型,使整個屬性的類型為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 def
在K
的約束和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.