ค้นหาเว็บไซต์

การพิมพ์โดยอิงตามชุดย่อยของยูเนียนใน 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 ไปยังโค้ด