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

วิธีที่ TypeScript forEach สามารถตรวจสอบชนิดของ union และเปลี่ยนชนิดของผลลัพธ์ได้อย่างไร


เราต้องการนำประเภทเอกสารมาและเปลี่ยนแปลงบางฟิลด์ของมัน จากนั้นคืนค่าเอกสารใหม่พร้อมประเภทใหม่ เราต้องการกำหนดฟิลด์ที่จะเปลี่ยนแปลงโดยอิงจากอาร์เรย์ของค่า (collectionDataFields ในตัวอย่างด้านล่าง)

type AllDocuments = {
    __typename: 'cat'
    a: '123',
    b: '123',
    z: 'xyz'
} | {
    __typename: 'dog'
    a: '123',
    c: '123',
    z: 'xyz'
}

const collectionDateFields = {
    cat: ['a', 'b'],
    dog: ['a', 'c']
} as const

export const useStringsToNums = (document: AllDocuments) => {
  const dateFields = collectionDateFields[document.__typename]

  dateFields.forEach((field) => {
    document[field] = parseInt(document[field])
  })
  return document
}

ในตัวอย่างนี้ dateFields ถูกกำหนดประเภทเป็น readonly ["a", "b"] | readonly ["a", "c"] แต่ field แต่ละตัวกลับมาเป็น any

เอกสาร ถูกส่งกลับเป็น AllDocuments แม้ว่าเราต้องการให้มันมีฟิลด์ที่มีชนิดข้อมูลที่เปลี่ยนแล้ว (เป็น number)

ตัวอย่างสนามเด็กเล่น TS

แก้ไข: สำหรับตัวอย่างการใช้งาน ฉันจะทำบางอย่างเช่นนี้:

useStringsToNums({
    __typename: 'cat'
    a: '123',
    b: '123',
    z: 'xyz'
})

ในกรณีนี้ ฟังก์ชันจะเปลี่ยนคุณสมบัติที่ตรงกับ __typename: 'cat' ให้เป็นตัวเลข (ด้วย parseInt)

คำตอบ #1

เราต้องการนำประเภทเอกสารหนึ่งมาแล้วปรับเปลี่ยนบางฟิลด์ของมัน

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

function doSomething(doc: AllDocuments) {
  doc.a = 123; // error! Type '123' is not assignable to type '"123"'    
}

คุณ สามารถ ใช้การยืนยันชนิดเพื่อปิดข้อผิดพลาดเหล่านี้ได้:

function doSomething(doc: AllDocuments) {
  doc.a = 123; // error! Type '123' is not assignable to type '"123"'    
}

แต่ทั้งหมดนี้ทำให้สถานการณ์แย่ลงในระยะยาว TypeScript ไม่ได้จำลองการเปลี่ยนประเภทแบบสุ่ม มันทำได้แค่การจำกัดประเภทเท่านั้น ไม่มีวิธีที่จะบอกว่าหลังจากเรียก doSomething(doc) ประเภทของ doc ได้เปลี่ยนไปแล้ว ทำให้คุณสมบัติที่เคยเป็น string กลายเป็น number โดยการยืนยันว่า number เป็น string คุณกำลังโกหกคอมไพเลอร์ และคุณอาจพบข้อผิดพลาดในขณะรันไทม์:

const doc: AllDocuments = { __typename: "cat", a: "123", b: "123" };
doSomething(doc);
try {
  doc.a.toUpperCase(); // compiles but has runtime error:
} catch (e) {
  console.log(e); // doc.a.toUpperCase is not a function    
}

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

function doSomethingElse(doc: AllDocuments) {
  return { ...doc, a: 123 }
}
const newDoc = doSomethingElse(doc);
/* const newDoc: {
  a: number;
  __typename: 'cat';
  b: '123';
 } | {
  a: number;
  __typename: 'dog';
  c: '123';
 } */
console.log(newDoc.a.toFixed(2)); // "123.00"

สำหรับส่วนที่เหลือของคำตอบนี้ ฉันจะใช้แนวทางนี้



ตอนนี้สำหรับส่วนที่เหลือของคำถามของคุณ คุณกำลังพบปัญหากับสิ่งที่ผมเรียกว่า ชนิดสหภาพที่มีความสัมพันธ์กัน ตามที่กล่าวถึงใน microsoft/TypeScript#30581

ในโค้ดต่อไปนี้:

  const dateFields = collectionDateFields[document.__typename]
  dateFields.forEach((field: "a" | "b" | "c") => {
    document[field] // oops
  })

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


บางครั้งมันเป็นไปได้ที่จะปรับปรุงโค้ดเพื่อหลีกเลี่ยงปัญหา correlated-union น่าเสียดายที่ แม้จะใช้เวลาพยายามทำให้บางอย่างค่อนข้างปลอดภัยตามประเภทจากตัวคอมไพเลอร์ สิ่งที่ใกล้เคียงที่สุดที่ฉันทำได้ก็ยังไม่เพียงพอที่จะคุ้มค่ากับความซับซ้อน:

const useDatesToLuxon = <T extends AllDocuments['__typename']>(
  document: Extract<AllDocuments, { __typename: T }>
) => {
  type Fields = (typeof collectionDateFields)[T][number];
  const dateFields: readonly Fields[] = collectionDateFields[document.__typename]
  const doc = document as Record<Fields, string>;
  const newDoc = {} as Record<Fields, number>;
  dateFields.forEach((field) => {
    newDoc[field] = parseInt(doc[field])
  })
  const ret = { ...(document as Omit<typeof document, Fields>), ...newDoc };
  type Ret = typeof ret;
  return ret as { [K in keyof Ret]: Ret[K] }
}

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

const r = useDatesToLuxon({ __typename: "cat", a: "123", b: "123" });
/* const r: {
    __typename: 'cat';
    a: number;
    b: number;
} */
console.log(r); // as expected

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

function butWait(doc: AllDocuments) {
  const u = useDatesToLuxon(doc);
  /* const u: {
    __typename: "cat" | "dog";
    a: number;
    b: number;
    c: number;
    } */
  // UGH, no, that's not the type we want; we cannot programmatically *distribute* the 
  // behavior of useDatesToLuxon across the union members of AllDocuments
}

งั้นเรามาละทิ้งการปรับโครงสร้างแบบนั้นกันเถอะ


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

export const useDatesToLuxon = (document: AllDocuments) => {
  switch (document.__typename) {
    case "cat": {
      const dateFields = collectionDateFields[document.__typename]
      const temp = {} as Record<typeof dateFields[number], number>
      dateFields.forEach((field) => {
        temp[field] = parseInt(document[field])
      })
      return { ...document, ...temp }
    }
    case "dog": {
      const dateFields = collectionDateFields[document.__typename]
      const temp = {} as Record<typeof dateFields[number], number>
      dateFields.forEach((field) => {
        temp[field] = parseInt(document[field])
      })
      return { ...document, ...temp }
    }
  }
}

สิ่งนี้ใช้งานได้ดีมาก และค่อนข้างปลอดภัยตามชนิดข้อมูล ในกรณี "cat" และในกรณี "dog" ตัวคอมไพเลอร์จะรู้ว่ากำลังเกิดอะไรขึ้นอย่างชัดเจน แต่ความซ้ำซ้อนแบบนี้ไม่สามารถขยายได้ มันจะดีถ้าคุณสามารถขอให้คอมไพเลอร์ แสร้งทำเหมือน ว่าคุณได้เขียนโค้ดซ้ำซ้อนแบบนี้ตามที่ผมร้องขอใน microsoft/TypeScript#25051 แต่คุณทำไม่ได้


คำแนะนำทั่วไปของฉันสำหรับสถานการณ์นี้คือการใช้การยืนยันชนิด (type assertions) เพื่อผ่อนคลายการตรวจสอบชนิดภายในการทำงานของฟังก์ชันของคุณ ซึ่งจะย้ายภาระหน้าที่ในการรักษาความปลอดภัยของชนิดจากคอมไพเลอร์ (ซึ่งไม่สามารถทำได้อย่างถูกต้องที่นี่) ไปยังคุณ ดังนั้นคุณต้องระมัดระวัง

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

type CollectionDateFields = typeof collectionDateFields;

type ConvertedDocuments = { 
  [D in AllDocuments as D['__typename']]: { 
    [K in keyof D]: K extends 
      CollectionDateFields[D['__typename']][number] ? number : D[K] 
} }[keyof CollectionDateFields]

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

/* type ConvertedDocuments = {
__typename: "cat";
a: number;
b: number;
} | {
__typename: "dog";
a: number;
c: number;
} */

ตอนนี้เราสามารถให้ useDatesToLuxon มีลายเซ็นการเรียกโอเวอร์โหลดเดียวที่อธิบายสิ่งที่มันทำได้:

function useDatesToLuxon<D extends AllDocuments>(
  document: D
): Extract<ConvertedDocuments, Pick<D, '__typename'>>

นั่นคือฟังก์ชันทั่วไปซึ่งสามารถเปลี่ยนทั้งยูเนียน AllDocuments ให้กลายเป็นทั้งยูเนียน ConvertedDocuments รวมถึงการเปลี่ยนซับไทป์ใด ๆ ให้กลายเป็นประเภทเอาต์พุตที่สอดคล้องกัน ตอนนี้สำหรับการใช้งาน:

function useDatesToLuxon(_document: AllDocuments): ConvertedDocuments {
  // let's just pretend it's one of the members of the union
  const document = _document as Extract<AllDocuments, { __typename: "cat" }>
  const dateFields = collectionDateFields[document.__typename]
  const temp = {} as Record<typeof dateFields[number], number>
  dateFields.forEach((field) => {
    temp[field] = parseInt(document[field])
  })
  return { ...document, ...temp }
}

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

และตอนนี้เพื่อทดสอบมัน:

const r = useDatesToLuxon({ __typename: "cat", a: "123", b: "123" });
/* const r: {
    __typename: 'cat';
    a: number;
    b: number;
} */
console.log(r); // as expected

function andThen(doc: AllDocuments) {
  const u = useDatesToLuxon(doc);
  /* {
    __typename: "cat";
    a: number;
    b: number;
  } | {
    __typename: "dog";
    a: number;
    c: number;
  } */

}

ดูดี!

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

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