angular - วิธีสร้างอ็อบเจ็กต์ตามชนิดและส่งค่ากลับสำหรับแต่ละคีย์เพียงค่าสตริงของคีย์แบบวนซ้ำ
ให้ฉันอธิบาย ฉันมีกรณีการใช้งานดังต่อไปนี้: ใน Angular ฉันกำลังใช้คอมโพเนนต์ตาราง UI ซึ่งมีลักษณะประมาณดังนี้
<my-table [values]="myTableValues">
<my-column field="firstColumn" />
</my-table>
myTableValues มีชนิดสำหรับแต่ละแถว (เรียกมันว่า myTableValuesRowType) ซึ่งถูกสร้างขึ้นผ่านตัวสร้างโค้ดของ Swagger อย่างที่คุณเห็น field รับค่าเป็นสตริง ซึ่งทำให้ความเข้มงวดของชนิดข้อมูลน้อยลง
ฉันคิดวิธีแก้ปัญหาที่สร้างวัตถุของชนิดเดียวกันโดยมีคุณสมบัติเหมือนกันและคืนค่าค่าเป็นสตริงของแต่ละคีย์ ดังนั้นโดยพื้นฐานก็เป็นประมาณนี้:
[Key]: KeyString
นี่คือโค้ดของฉัน:
type NonNullableKeys<T> = {
[K in keyof T]: T[K] extends null | undefined ? never : K;
}[keyof T];
export type PropertiesOf<T> = Record<NonNullableKeys<T>, string>;
export function getTypeProperties<T>(): PropertiesOf<T> {
return new Proxy({}, {
get: (_target, prop: string) => prop,
}) as PropertiesOf<T>;
}
ตัวอย่าง Typescript Playground
นี่ทำให้เราสามารถทำให้คุณสมบัติ field ในเทมเพลต HTML มีความเข้มงวดด้านประเภทมากขึ้น อาจจะไม่ใช่วิธีแก้ที่ดีที่สุด แต่เป็นวิธีแก้ที่ดีที่สุด/ง่ายที่สุดที่เราสามารถใช้ได้ตอนนี้ ตัวอย่างการใช้งาน:
//component.ts
readonly rowProperties = getTypeProperties<myTableValuesRowType>();
//component.html
<my-table [values]="myTableValues">
<my-column [field]="rowProperties.firstColumn" />
</my-table>
ด้วยวิธีนี้ หากชนิดที่สร้างขึ้นมีการเปลี่ยนแปลงคีย์ของสมบัติ ผมจะสามารถตรวจสอบได้ทันที
อย่างไรก็ตาม มีปัญหาหลักหนึ่ง ฟังก์ชันข้างต้นไม่ใช่ฟังก์ชันแบบเรียกซ้ำ ดังนั้นคุณสมบัติที่ซ้อนกันจะถูกละเลย
ตัวอย่าง :
type myTableValuesRowType = {
firstColumn: number;
secondColumn: string;
thirdColumn: number[];
fourthColumn: boolean;
subrow: {
firstSubrowColumn: string;
secondSubrowColumn: number;
}
}
const rowProperties = getTypeProperties<myTableValuesRowType>();
console.log(rowProperties.firstColumn); //returns "firstColumn"
console.log(rowProperties.subrow.firstSubrowColumn); //returns undefined & TS-Error: Property 'firstSubrowColumn' does not exist on type 'string'. expecting to return "subrow.firstSubrowColumn"
สุดท้ายคำถามของฉันคือ ฉันจะทำให้มันเป็นแบบเรียกซ้ำได้อย่างไร? ฉันได้ลองวิธีหลายแบบและค้นคว้าเพื่อหาบางสิ่งที่คล้ายกัน สิ่งที่ฉันพบคือการได้คีย์ซ้อนกันในประเภทอื่น แต่ไม่ใช่การดึงคุณสมบัติออกและสร้างวัตถุมันจริงๆ
เพื่อให้ทราบ ฉันไม่สามารถเปลี่ยนไลบรารีคอมโพเนนต์ส่วนติดต่อผู้ใช้ได้...
ฉันจะขอบคุณสำหรับความช่วยเหลือหรือข้อมูลเชิงลึกใดๆ!
อัปเดต: ฉันเจอบางสิ่งที่คล้ายกันมาก อย่างไรก็ตามมันจะแปลงเป็นฟังก์ชันและมันไม่ได้ดูดีเท่าจะเป็นวัตถุ
อัปเดตอีกครั้ง: ฉันลืมบอกไปว่าฉันคาดว่า rowProperties.subrow.firstSubrowColumn จะส่งกลับ subrow.firstSubrowColumn ไม่ใช่แค่คีย์ลูก firstSubrowColumn
คำตอบ #1
ชนิดเช่น MyTableValuesRowType จะถูกลบเมื่อ TypeScript ถูกคอมไพล์เป็น JavaScript ซึ่งหมายความว่าเมื่อคุณเรียก getTypeProperties<MyTableValuesRowType>() การเรียกใช้งานในเวลารันไทม์จะเหมือนกับ getTypeProperties() และถ้าคุณเรียก getTypeProperties<SomeOtherRowType>() ก็จะกลายเป็น getTypeProperties() ในเวลารันไทม์เช่นกัน ดังนั้นการทำงานของ getTypeProperties() จึงไม่สามารถรู้ได้ว่าคุณกำลังถามถึงชนิดใด สิ่งที่มันทำต้องเป็น อิสระ จากชนิดใน TypeScript
นั่นหมายความว่าสิ่งที่คุณกำลังขอเป็นไปไม่ได้ตามที่กล่าวไว้ ไม่มีทางที่ rowProperties.firstColumn จะเป็น string ในขณะที่ rowProperties.subrow เป็น object ทั้งคู่จะต้องเป็น string หรือเป็น object คุณต้องเลือกว่าจะ: ยอมแพ้โดยสิ้นเชิง; หรือปรับปรุงโค้ดของคุณใหม่เพื่อส่งบางอย่างที่เป็น schema object ซึ่งมีอยู่ในขณะ runtime แต่แทนรูปแบบของ interface; หรือคุณสามารถเปลี่ยนพฤติกรรมที่ตั้งใจไว้ของ getTypeProperties() เพื่อให้สามารถทำงานอย่างไม่ขึ้นกับประเภทแต่เพียงพอที่จะบรรลุเป้าหมายของคุณ
เป็นตัวอย่างของข้อหลัง มาทำให้ rowProperties.firstColumn และ rowProperties.subrow เป็นวัตถุทั้งคู่ และถ้าคุณต้องการชื่อคีย์จากนั้น คุณสามารถเข้าถึงวัตถุด้วยคีย์พิเศษ เพื่อไม่ให้รบกวนคีย์ที่มีอยู่แล้ว ผมขอแนะนำให้เราใช้ symbol:
const keyName = Symbol("keyName");
เมื่อเราทำสิ่งนั้นแล้ว เราสามารถเขียน getTypeProperties() เพื่อให้มันส่งกลับวัตถุเสมอ ซึ่งวัตถุนั้นจะให้สตริงที่ต้องการเมื่อคุณเข้าดัชนีด้วย keyName และในกรณีอื่น ๆ จะส่งกลับวัตถุอีกชิ้นหนึ่งของประเภทเดียวกัน เช่นนี้:
function getTypeProperties<T extends object>(
key?: string
): { [K in keyof T]: PropertiesOf<T[K]> } {
return new Proxy({}, {
get: (_target, prop: string | symbol) =>
typeof prop === "symbol" ? key :
getTypeProperties(typeof key === "string" ? key + "." + prop : prop)
}) as any
}
ที่นี่ผมได้เพิ่มพารามิเตอร์ตัวเลือกใน getTypeProperties() ซึ่งใช้ติดตามคีย์ที่ต้องการส่งกลับที่พร็อพเพอร์ตี keyName สำหรับการเรียกในระดับบนสุด มันก็เพียง undefined เพราะไม่มีชื่อคีย์ในระดับนั้น ภายใน getter ของ Proxy มันแค่ตรวจสอบว่าพร็อพเพอร์ตีนั้นเป็นสัญลักษณ์หรือไม่ (คุณ สามารถ ทำให้มันตรวจสอบเฉพาะ keyName ได้ แต่คุณต้องตัดสินใจว่าต้องการแสดงสตริงอะไรสำหรับคีย์สัญลักษณ์ และนั่นอยู่นอกขอบเขตที่นี่) ถ้ามันเป็นสัญลักษณ์ คุณจะได้รับคีย์ที่ถูกส่งเข้ามา มิฉะนั้นมันเป็นคีย์สตริงและเราจะส่งค่าผลลัพธ์จากการเรียกใหม่ของ getTypeProperties() ซึ่งจะต่อชื่อพร็อพเพอร์ตีปัจจุบันเข้ากับเส้นทางที่มีอยู่ โดยแทรกจุดระหว่างกลางหากเส้นทางของแม่ไม่ใช่ undefined.
ดังนั้นพฤติกรรมจึงดูเหมือน
type MyTableValuesRowType = {
firstColumn: number;
secondColumn: string;
thirdColumn: number[];
fourthColumn: boolean;
subrow: {
firstSubrowColumn: string;
secondSubrowColumn: number;
}
}
const rowProperties = getTypeProperties<MyTableValuesRowType>();
console.log(rowProperties.firstColumn[keyName]); // returns "firstColumn"
console.log(rowProperties.subrow.firstSubrowColumn[keyName]); // returns "subrow.firstSubrowColumn"
IDE ของ TypeScript ของคุณจะรู้ว่าคุณสมบัติใดที่คาดว่าจะมีอยู่ (เมื่อเรานำ PropertiesOf<> ไปใช้งานด้านล่าง) แต่จงจำไว้ว่าในเวลารันไทม์ มันไม่สนใจจริงๆ แท้จริงแล้วคุณสามารถเรียกใช้งานด้วยชื่อคีย์แบบสุ่มและรับผลลัพธ์ในเวลารันไทม์ได้:
console.log(rowProperties.foo.bar.baz[keyName]); // returns "foo.bar.baz"
// ~~~
// Property 'foo' does not exist on type '⋯'.(2339)
ประโยชน์ของการพิมพ์ที่นี่คือเพื่อทำให้บรรทัดด้านบนเป็นข้อผิดพลาดใน TypeScript ในเวลาคอมไพล์ แม้ว่าจะไม่มีอะไร 'แย่' เกิดขึ้นในเวลารันไทม์
โอเค ตอนนี้... เราจะสามารถใช้งาน PropertiesOf<T> ได้อย่างไร? วิธีการหนึ่งดูเหมือนว่าจะเป็นดังนี้:
type PropertiesOf<T> = T extends object ?
T extends readonly any[] ? { [keyName]: string } :
{ [keyName]: string } & { [K in keyof T]: PropertiesOf<T[K]> } :
{ [keyName]: string };
มันเป็นประเภทตามเงื่อนไขที่ตรวจสอบว่า T เป็นอ็อบเจ็กต์ที่ไม่ใช่อาร์เรย์หรือไม่ (อาร์เรย์เป็นอ็อบเจ็กต์ใน JS/TS ดังนั้นคุณต้องตรวจสอบ) หากเป็นเช่นนั้น มันจะระบุว่ามีสมบัติ string ที่คีย์สัญลักษณ์ keyName จากนั้นทำการแมปผ่านสมบัติของ T เพื่อใช้ PropertiesOf กับแต่ละสมบัติแบบเรียกซ้ำ และรวมประเภทรวมกัน (ดังนั้นมันจึงมีทั้งคีย์ keyName และคีย์เดิมทั้งหมดด้วย) มิฉะนั้น ถ้าเป็นอาร์เรย์ หรือไม่ใช่อ็อบเจ็กต์ มันจะมีเพียงสมบัติ string เดียวที่คีย์สัญลักษณ์ keyName เท่านั้น
นั่นใช้ได้ แต่จะสร้าง IntelliSense types ที่มีการรวมกันหลายชั้น ผมมักจะชอบ "ทำให้สวย" มันด้วยอะไรบางอย่างเช่น:
type Id<T> = T extends any ? { [K in keyof T]: T[K] } : never
type PropertiesOf<T> = T extends object ?
T extends readonly any[] ? { [keyName]: string } :
Id<{ [keyName]: string } & { [K in keyof T]: PropertiesOf<T[K]> }> :
{ [keyName]: string };
โดยที่ Id<T> แค่รวม T เข้าเป็นชนิดเดียว ตอนนี้ สังเกตว่าลายเซ็นการเรียกของ getTypeProperties() คือ
declare function getTypeProperties<T extends object>(
key?: string
): { [K in keyof T]: PropertiesOf<T[K]> };
ซึ่งหมายความว่าชนิดของวัตถุระดับบนสุดที่ส่งกลับ ไม่มี คุณสมบัติ keyName (อีกครั้ง นั่นเป็นเพียงชนิดของ TypeScript เท่านั้น พฤติกรรมในเวลารันไทม์ไม่ได้รู้เกี่ยวกับสิ่งนั้น) ดังนั้นตอนนี้ ถ้าเราดูที่ชนิดของการเรียกใช้นี้:
const rowProperties = getTypeProperties<MyTableValuesRowType>();
/* const rowProperties: {
[keyName]: string;
firstColumn: {
[keyName]: string;
};
secondColumn: {
[keyName]: string;
};
thirdColumn: {
[keyName]: string;
};
fourthColumn: {
[keyName]: string;
};
subrow: {
[keyName]: string;
firstSubrowColumn: {
[keyName]: string;
};
secondSubrowColumn: {
[keyName]: string;
};
};
} */
คุณสามารถเห็นโครงสร้างแบบวนซ้ำที่เราต้องการ แต่ละคุณสมบัติของ rowProperties เป็นวัตถุที่มีคุณสมบัติที่มีค่า string อยู่ที่คีย์ keyName และคุณสมบัติ subrow มีคุณสมบัติเพิ่มเติมที่ firstSubrowColumn และ secondSubrowColumn
ลิงก์ Playground ไปยังโค้ด
