การพิมพ์โดยอิงตามชุดย่อยของยูเนียนใน 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 (ใช่ นี่ได้แรงบันดาลใจมาจาก 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"] ในกรณีนี้) แต่สิ่งนั้นจะสมเหตุสมผลจริงๆ เฉพาะเมื่อไม่ได้ใช้ตัวจับคู่อื่นใดและถูกแทนที่ด้วยตัวจับคู่เริ่มต้นเท่านั้น
ฉันอยากจะสามารถพิมพ์พารามิเตอร์ value ของตัวจับคู่เริ่มต้นโดยอิงตามว่าตัวจับคู่อื่น ๆ ถูกจัดเตรียมอย่างไร เช่น ในตัวอย่างข้างต้น ควรเป็น 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 ได้ถูกต้องเช่นกัน (มันคืนค่าที่ถูกส่งกลับมาจาก matcher ที่ถูกเรียกใช้) นี่เป็นเรื่องข้างเคียงเกี่ยวกับสิ่งที่ผมถามที่นี่ แต่ก็อาจน่าสนใจสำหรับใครก็ตามที่พบเจอสิ่งนี้ในอนาคต: playground
คำตอบ #1
ต่อไปนี้ฉันจะสนใจเฉพาะการพิมพ์ และเฉพาะจากมุมมองของ ผู้เรียก ของ match() เท่านั้น การใช้งานจริงอาจต้องมีการยืนยันประเภทหรือสิ่งที่คล้ายกันเพื่อป้องกันข้อผิดพลาดของคอมไพเลอร์ เนื่องจากลายเซ็นการเรียกใช้งานทั่วไปที่ซับซ้อนมักตรวจสอบยาก
ความโน้มเอียงของฉันที่นี่คือการโอเวอร์โหลด match() เพื่อให้กรณี "จับทั้งหมด" ที่ครอบคลุมเต็มรูปแบบถูกจัดการแยกจากกรณี "บางส่วน" ที่มีตัวจับพร้อมค่าเริ่มต้น
สำหรับกรณี "บางส่วน" เราต้องการให้ match() เป็นแบบทั่วไปไม่ใช่เพียงใน T ซึ่งเป็นประเภทยูเนียนของอาร์กิวเมนต์ variant เท่านั้น แต่ยังรวมถึง K ซึ่งเป็นคีย์ของอาร์กิวเมนต์ matchers ด้วย และเราน่าจะทำให้ Matchers เป็นแบบทั่วไปทั้งใน T และ K
ดังนั้นนี่คือนิยามที่เป็นไปได้ของ 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 };
แนวคิดคือเราสร้างแมปเหนือแต่ละองค์ประกอบ P ของ K เพื่อให้ได้ callback ที่ส่งกลับ any เพื่อหาประเภทพารามิเตอร์ value ของ callback ที่มีคีย์ P: หาก P เป็นประเภทของ def ดังนั้น value จะสอดคล้องกับทุกตัวแปรที่ไม่ได้กล่าวถึงใน K (ซึ่งเราสร้างโดยใช้ประเภทยูทิลิตี้ Exclude<T, U>) มิฉะนั้น เราจะสมมติว่า P เป็นหนึ่งในแท็กจาก T ในกรณีนั้น value จะสอดคล้องกับค่าของตัวแปรที่มีแท็กนั้น (ซึ่งเราสร้างโดยใช้ประเภทยูทิลิตี้ Extract<T, U>)
โปรดทราบว่าหาก K เกิดขึ้นเป็นการรวมทั้งหมดของ T["tag"] ตัว callback [def] จะมีอาร์กิวเมนต์ value ที่มีชนิดเป็น never นี่อาจดูแปลกเล็กน้อย แต่โดยทั่วไปไม่น่าจะเป็นอันตราย หากมันสำคัญจริง ๆ คุณสามารถเปลี่ยนชนิดนี้ได้เพื่อให้คุณสมบัติทั้งหมดเป็นชนิด never แทนที่จะเป็นแค่อาร์กิวเมนต์ของ callback เท่านั้น
และนี่คือลายเซ็นการเรียกที่ถูกโอเวอร์โหลดของเรา:
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;
ซิกเนเจอร์แรกจัดการกับกรณี "partial" ด้วยคุณสมบัติ def การอนุมานค่อนข้างซับซ้อนตรงนี้; ฉันพบว่าจำเป็นต้องระบุ | typeof def ทั้งในข้อจำกัดสำหรับ K และภายในอาร์กิวเมนต์ตัวที่สองของ Matchers มิฉะนั้นคอมไพเลอร์มักจะ "ยอมแพ้" ในการอนุมาน K จากคีย์จริงของอาร์กิวเมนต์ matchers
ลายเซ็นที่สองจัดการกรณี "จับคู่ทั้งหมด" โดยไม่มีคุณสมบัติ def และไม่จำเป็นต้องเป็น generic ใน K (เนื่องจากมันจะเป็นเพียงการ union ของ 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 ในแต่ละ callback ตอนนี้มาละเว้นคีย์ str และเพิ่มคีย์ [def]:
match(adt, {
num: v => v.toFixed(),
dat: v => v.toISOString(),
[def]: v => v.toUpperCase()
});
ก็ดูดีเช่นกัน; คอมไพเลอร์ทราบว่า v ต้องเป็น string ในตัวจับตรงตามค่าเริ่มต้น ตอนนี้ลองเว้น key dat ไว้ด้วย:
match(adt, {
num: v => v.toFixed(),
[def]: v => typeof v === "object" ? v.toISOString() :
v.toUpperCase()
});
ยังดีอยู่; ประเภทของ v ตอนนี้คือ Date | string และท้ายที่สุดเรามีตัวจับคู่เริ่มต้นเท่านั้น:
match(adt, {
[def]: v => typeof v === "number" ? v.toFixed() :
typeof v === "object" ? v.toISOString() :
v.toUpperCase()
})
ประเภทของ v คือการรวมแบบเต็ม 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'?
})
ข้อผิดพลาดนี้อธิบายว่ามันไม่คาดว่าจะเห็นคีย์ที่สะกดผิดนั้น และ (ขึ้นอยู่กับความใกล้เคียงของการสะกดของเรา) จะแนะนำวิธีแก้ไข
ดังนั้นคุณก็จะเห็นว่า โดยการเพิ่มลายเซ็นการเรียกใช้งานที่สองซึ่งเป็นแบบทั่วไปในคีย์ของตัวจับคู่ เราสามารถได้รับการอนุมานในลักษณะนั้นสำหรับอาร์กิวเมนต์ callback เริ่มต้นที่คุณกำลังมองหา
ลิงก์ Playground ไปยังโค้ด
