วิธีที่ 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 ไปยังโค้ด
