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

ฟังก์ชันประเภทยูเนียนของ TypeScript


ฉันกำลังพยายามสร้างประเภทยูเนียนสำหรับฟังก์ชันลูกศร เป้าหมายคือสามารถอนุมานประเภทของอาร์กิวเมนต์ตัวที่สองโดยขึ้นอยู่กับตัวแรก

ในอุดมคติฉันต้องการให้ y เป็นประเภท number | string และ z เป็น 'first' | 'second' แต่หลังจากจำกัดประเภทของ y หรือ z ให้สามารถคาดเดาประเภทที่จำกัดของพารามิเตอร์อีกตัวโดยอัตโนมัติ

น่าเสียดายที่ TypeScript ดูเหมือนไม่สามารถจัดการกับกรณีซับซ้อนแบบนี้ได้ แต่ฉันสงสัยว่าพวกคุณเคยเจอปัญหาคล้าย ๆ กันบ้างหรือไม่

ในสถานการณ์ที่เรียบง่าย โค้ดของฉันมีลักษณะดังนี้:

type Callback<T1, T2> = (y: T1, z: T2) => void;

const test = (x: Callback<number, 'first'> | Callback<string, 'second'>) => {
    return;
}

// Parameter 'y' implicitly has an 'any' type.
// Parameter 'z' implicitly has an 'any' type.
test((y, z) => {
    if(typeof y === 'number') {
        // y is a number
        // so z must be 'first'
    } else {
        // y is a string
        // so z must be 'second'
    }
});

ขอบคุณ!

คำตอบ #1

นี่คือสิ่งที่กำลังเกิดขึ้นตามที่ผมเห็น ลองใช้คำนิยามเหล่านี้:

type Callback<T1, T2> = (y: T1, z: T2) => void;
type First = Callback<number, 'first'>;
type Second = Callback<string, 'second'>;

ก่อนอื่น ผมแน่ใจเลยว่าผมสงสัยว่าคุณต้องการสหภาพของฟังก์ชันมากกว่าการตัดกันของฟังก์ชัน สังเกตว่าการสหภาพของฟังก์ชันเช่นนี้แทบจะไร้ประโยชน์:

const unionTest = (x: First | Second) => {
  // x is *either* a First *or* it is a Second, 
  // *but we don't know which one*.  So how can we ever call it?

  x(1, "first"); // error! 
  // Argument of type '1' is not assignable to parameter of type 'never'.
  x("2", "second"); // error!
  // Argument of type '"2"' is not assignable to parameter of type 'never'.
}

ฟังก์ชัน unionTest() เหมือนกับ test() ของคุณ แต่ไม่สามารถทำอะไรกับ x ได้ เพราะ x เป็นเพียง First หรือ Second หากคุณพยายามเรียกใช้ จะเกิดข้อผิดพลาดไม่ว่าจะอย่างไร union ของฟังก์ชันสามารถทำงานได้อย่างปลอดภัยเพียงกับ intersection ของพารามิเตอร์ของพวกมัน เทคโนโลยีบางส่วนสำหรับสิ่งนี้ถูกเพิ่มเข้ามาใน TS3.3 แต่ในกรณีนี้ประเภทของพารามิเตอร์เป็นแบบที่ไม่สามารถเกิดขึ้นพร้อมกันได้ ดังนั้นพารามิเตอร์ที่ยอมรับได้มีเพียงประเภท never ... ดังนั้น x จึงไม่สามารถเรียกใช้ได้

ฉันสงสัยว่าการรวมฟังก์ชันที่ไม่สามารถเข้ากันได้แบบนี้เคยเป็นสิ่งที่ใครต้องการจริง ๆ หรือไม่ ความเป็นคู่ของการรวมและการตัดกัน และความตรงข้ามของประเภทฟังก์ชันเมื่อเทียบกับประเภทของพารามิเตอร์นั้นทำให้สับสนและยากที่จะพูดถึง แต่ความแตกต่างนี้สำคัญ ดังนั้นฉันคิดว่าคุ้มค่าที่จะอธิบายจุดนี้ให้ชัดเจน การรวมนี้เหมือนกับการพบว่าฉันต้องจัดประชุมกับใครบางคนที่ อาจว่างในวันจันทร์ หรือจะว่างในวันอังคาร แต่ฉันไม่รู้ว่าเป็นวันไหน ฉันคงจะคิดว่าถ้าฉันสามารถมีประชุมทั้งในวันจันทร์ และวันอังคารได้นั่นจะใช้ได้ แต่สมมติว่าสิ่งนั้นไม่มีเหตุผล ฉันจึงติดอยู่ คนที่ฉันจะพบคือการรวม และวันที่ฉันจะพบคือการตัดกัน ทำไม่ได้


แทนที่จะเป็นเช่นนั้น สิ่งที่ฉันคิดว่าคุณต้องการคือ สัญจรตัดกัน ของฟังก์ชัน ซึ่งนี่คือสิ่งที่สอดคล้องกับฟังก์ชันที่มีการโอเวอร์โหลด; คุณสามารถเรียกใช้ได้ทั้งสองวิธี มันจะมีลักษณะดังนี้:

const intersectionTest = (x: First & Second) => {
  // x is *both* a First *and* a Second, so we can call it either way:
  x(1, "first"); // okay!
  x("2", "second"); // okay!
  // but not in an illegal way:
  x(1, "second"); // error, as desired
  x("2", "first"); // error, as desired
}

ตอนนี้เรารู้ว่า x เป็นทั้ง First และ Second คุณสามารถเห็นได้ว่าคุณสามารถจัดการมันเหมือนกับ First หรือเหมือนกับ Second และจะไม่มีปัญหา แต่อย่าพยายามจัดการมันเหมือนกับไฮบริดแปลก ๆ อย่าง x(1, "second") เพราะนั่นอาจไม่ใช่สิ่งที่คุณต้องการ ตอนนี้ฉันกำลังนัดประชุมกับคนที่สามารถเข้าร่วมได้ทั้งวันจันทร์ และ วันอังคาร ถ้าฉันถามคนคนนั้นว่านัดประชุมวันไหนดี เขาอาจจะตอบว่า "วันจันทร์หรือวันอังคารก็ได้สำหรับฉัน" วันที่ประชุมเป็นการรวมตัว และคนที่ฉันจะประชุมด้วยเป็นการตัดกัน เรื่องนี้ใช้ได้


ดังนั้นตอนนี้ผมสมมติว่าคุณกำลังจัดการกับการตัดกันของฟังก์ชัน อย่างไรก็ตาม คอมไพเลอร์จะไม่สร้างชนิดพารามิเตอร์รวมให้คุณโดยอัตโนมัติ และคุณก็ยังจะเจอข้อผิดพลาด “implicit any” นั้นอยู่ดี

// unfortunately we still have the implicitAny problem:
intersectionTest((x, y) => { }) // error! x, y implicitly any

คุณสามารถ แปลงด้วยตนเอง การตัดกันของฟังก์ชันให้เป็นฟังก์ชันเดียวที่ทำงานกับสหภาพของประเภทพารามิเตอร์ แต่กับสองพารามิเตอร์ที่ถูกจำกัด วิธีเดียวที่จะแสดงสิ่งนี้คือด้วยอาร์กิวเมนต์ที่เหลือและทูเพิลที่เหลือ นี่คือวิธีที่เราสามารถทำได้:

const equivalentToIntersectionTest = (
  x: (...[y, z]: Parameters<First> | Parameters<Second>) => void
) => {
  // x is *both* a First *and* a Second, so we can call it either way:
  x(1, "first"); // okay!
  x("2", "second"); // okay!
  // but not in an illegal way:
  x(1, "second"); // error, as desired
  x("2", "first"); // error, as desired
}

นั่นเหมือนกับ intersectionTest() ในแง่ของพฤติกรรม แต่ตอนนี้พารามิเตอร์มีชนิดที่ทราบแล้วและสามารถกำหนดชนิดตามบริบทให้ดีกว่า any:

equivalentToIntersectionTest((y, z) => {
  // y is string | number
  // z is 'first' | 'second'
  // relationship gone
  if (z === 'first') {
    y.toFixed(); // error!
  }
})

น่าเสียดาย ตามที่คุณเห็นด้านบน หากคุณใช้งาน callback นั้นด้วย (y, z) => {...} ประเภทของ y และ z จะกลายเป็นสหภาพที่แยกออกกัน คอมไพเลอร์จะลืมว่าพวกมันเกี่ยวข้องกันทันที เมื่อคุณพิจารณารายการพารามิเตอร์เป็นพารามิเตอร์แยก คุณจะสูญเสียความสัมพันธ์นั้น ผมเคยเห็นคำถามมากพอที่ต้องการวิธีแก้ปัญหานี้จนผมได้ยื่นข้อร้องเรียนเกี่ยวกับมัน แต่ตอนนี้ยังไม่มีการสนับสนุนโดยตรง

ลองดูว่าจะเกิดอะไรขึ้นหากเราไม่แยกรายการพารามิเตอร์ทันที โดยการกระจายพารามิเตอร์ที่เหลือไปยังอาร์เรย์และใช้มันดังนี้:

equivalentToIntersectionTest((...yz) => {
  // yz is [number, "first"] | [string, "second"], relationship preserved!

โอเค นั่นดี ตอนนี้ yz ยังคงติดตามข้อจำกัดอยู่


ขั้นตอนต่อไปที่นี่คือการพยายามจำกัด yz ให้เหลือเป็นขาใดขาหนึ่งของสหภาพผ่านการทดสอบ type guard วิธีที่ง่ายที่สุดในการทำเช่นนี้คือถ้า yz เป็น discriminated union และมัน เป็น แต่ไม่ใช่เพราะ y (หรือ yz[0]) number และ string ไม่ใช่ประเภท literal และไม่สามารถใช้โดยตรงเป็น discriminator ได้:

  if (typeof yz[0] === "number") {
    yz[1]; // *still* 'first' | 'second'.  
  }

ถ้าคุณต้องตรวจสอบ yz[0] คุณจะต้องสร้างฟังก์ชัน type guard ของคุณเองเพื่อรองรับสิ่งนั้น แทนที่นั้น ผมขอแนะนำให้สลับไปที่ z (หรือ yz[1]) เนื่องจาก "first" และ "second" เป็นค่า string literal ที่สามารถใช้ในการแยกประเภท union ได้:

  if (yz[1] === 'first') {
    // you can only destructure into y and z *after* the test
    const [y, z] = yz;
    y.toFixed(); // okay
    z === "first"; // okay
  } else {
    const [y, z] = yz;
    y.toUpperCase(); // okay
    z === "second"; // okay
  }
});

สังเกตว่า หลังจากที่ yz[1] ถูกเปรียบเทียบกับ 'first' แล้ว ประเภทของ yz ไม่ใช่ union อีกต่อไป ดังนั้นคุณสามารถแยกค่าไปที่ y และ z ในลักษณะที่มีประโยชน์มากขึ้น


โอเค ฟู้วว นั่นเยอะมาก รหัสสรุป:

const test = (
  x: (...[y, z]: [number, "first"] | [string, "second"]) => void
) => { }

test((...yz) => {
  if (yz[1] === 'first') {
    const [y, z] = yz;
    y.toFixed();
  } else {
    const [y, z] = yz;
    y.toUpperCase(); // okay
  }
});

หวังว่านั่นจะช่วยได้ โชคดีนะ!

ลิงก์ไปยังโค้ด

บทความที่เกี่ยวข้อง