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

วิธีการดึงค่าของอ็อบเจ็กต์เป็นชนิดลิตเทอรัลยูเนียนในไทป์สคริปต์


ฉันกำลังดำเนินการฟังก์ชัน groupBy และมันก็เป็นประมาณนี้:

export const groupBy = <T extends Record<string, unknown>, U extends keyof T>(
  objArr: T[],
  property: U,
): { [key in T[U]]: T[] } => objArr
    .reduce((memo, x) => {
      const value = x[property];
      if (!memo[value]) {
        memo[value] = [];
      }
      memo[value].push(x);
      return memo;
    }, {} as { [key in T[U]]: Array<T> });

ฉันรู้ว่าการพิมพ์นั้นผิดพลาด ฉันก็ลองปรับมันมากเกินไปเช่นกัน:

const data = [
   { name: 'corn cob', value: 17, group: 'item' },
   { name: 'Dirty toilet', value: 6, group: 'item' },
   { name: 'snake', value: 2, group: 'animal' },
   { name: 'tesla', value: 17, group: 'car' },
   { name: 'gurgel', value: 23, group: 'car' },
  ];

const result = groupBy(data, 'group')

{
  item: [
    { name: 'corn cob', value: 17, group: 'item' },
    { name: 'Dirty toilet', value: 6, group: 'item' }
  ],
  animal: [ { name: 'snake', value: 2, group: 'animal' } ],
  car: [
    { name: 'tesla', value: 17, group: 'car' },
    { name: 'gurgel', value: 23, group: 'car' }
  ]
}

นี่คือชนิดที่ปลอดภัยสูงสุดที่ฉันสามารถหาได้สำหรับสิ่งนี้ และฉันยังคงพบข้อผิดพลาดบางอย่างใน T[U].

Type 'T[U]' is not assignable to type 'string | number | symbol'.

และประเภทการส่งค่ากลับเป็นดังนี้:

const result: {
    [x: string]: {
        name: string;
        value: number;
        group: string;
    }[];
}

มีวิธีใดที่ฉันสามารถได้ประเภทค่าตอบแทนแบบนี้หรือไม่:

const result: {
    item: {
        name: string;
        value: number;
        group: string;
    }[];
    animal: {
        name: string;
        value: number;
        group: string;
    }[];
    car: {
        name: string;
        value: number;
        group: string;
    }[];
}

ถ้าฉันสามารถดึงค่าของวัตถุอินพุตทั้งหมดของคีย์ที่กำหนดออกมาเป็นตัวอักษรตรง ๆ ได้เหมือน:

const test = [
  { name: 'corn cob', value: 17, group: 'item' },
  { name: 'Dirty toilet', value: 6, group: 'item' },
  { name: 'snake', value: 2, group: 'animal' },
  { name: 'tesla', value: 17, group: 'car' },
  { name: 'gurgel', value: 23, group: 'car' },
] as const ;


type ValueAtKey = (typeof test)[number]['group']; // "item" | "animal" | "car"

แต่ฉันจะทำ const asertion ภายในฟังก์ชันเจนเนอริกได้อย่างไร?

ทำสิ่งแบบนี้:

type SomeMagicType<T extends Record<string, unknown>[], U extends keyof T[number]> = T[number][U];

export const groupBy = <T extends Record<string, unknown>, U extends keyof T>(
  objArr: T[],
  property: U,
): { [key in SomeMagicType<T, U>]: T[] } => objArr
    .reduce((memo, x) => {
      const value = x[property];
      if (!memo[value]) {
        memo[value] = [];
      }
      memo[value].push(x);
      return memo;
    }, {} as { [key in SomeMagicType<T, U>]: Array<T> });

ถ้าเป็นไปไม่ได้ งั้นฉันจะลบข้อผิดพลาดข้างต้นได้อย่างไร ฉันไม่เข้าใจว่าเหตุใด Type 'T[U]' is not assignable to type 'string | number | symbol' ถ้า T extends Record<string, unknown>

TSPlayground

คำตอบ #1

ก่อนอื่น หากคุณต้องการให้ประเภทผลลัพธ์ของ groupBy มีคีย์เฉพาะอย่าง item, animal, และ car คุณจะต้องทำบางอย่างเหมือนกับการยืนยันแบบ const เมื่อคุณเริ่มต้น data โดยทั่วไป คอมไพเลอร์จะอนุมานประเภทของสมบัติที่มีค่าเป็นสตริงให้เป็น string ไม่ใช่ประเภทสตริงลิเทอรัลเฉพาะ ดังนั้นประเภทของ x ใน const x={a: "b"} จะถูกอนุมานเป็น {a: string} ไม่ใช่ {a: "b"} เนื่องจาก "item", "animal" และ "car" เป็นสมบัติที่มีค่าเป็นสตริงของ data คุณจะต้องทำงานบางอย่างเพื่อรักษาค่าของพวกมัน วิธีที่ง่ายที่สุดในการทำคือ:

const data = [
  { name: 'corn cob', value: 17, group: 'item' },
  { name: 'Dirty toilet', value: 6, group: 'item' },
  { name: 'snake', value: 2, group: 'animal' },
  { name: 'tesla', value: 17, group: 'car' },
  { name: 'gurgel', value: 23, group: 'car' },
] as const;

แม้ตอนนี้คอมไพเลอร์จะจำข้อมูลเกี่ยวกับโครงสร้างของ data ได้ค่อนข้างมาก:

/* const data: readonly [{
    readonly name: "corn cob";
    readonly value: 17;
    readonly group: "item";
}, {
    readonly name: "Dirty toilet";
    readonly value: 6;
    readonly group: "item";
}, {
    readonly name: "snake";
    readonly value: 2;
    readonly group: "animal";
}, {
    readonly name: "tesla";
    readonly value: 17;
    readonly group: "car";
}, {
    readonly name: "gurgel";
    readonly value: 23;
    readonly group: "car";
}] */

และคุณสนใจเพียงประเภทตัวอักษรของ group จริงๆ คุณสามารถทำอะไรที่ซับซ้อนกว่านี้ได้ เช่น

const data = [
  { name: 'corn cob', value: 17, group: 'item' as const },
  { name: 'Dirty toilet', value: 6, group: 'item' as const },
  { name: 'snake', value: 2, group: 'animal' as const },
  { name: 'tesla', value: 17, group: 'car' as const },
  { name: 'gurgel', value: 23, group: 'car' as const },
];

ซึ่งส่งผลให้

/* const data: ({
    name: string;
    value: number;
    group: "item";
} | {
    name: string;
    value: number;
    group: "animal";
} | {
    name: string;
    value: number;
    group: "car";
})[] */

ซึ่งผมว่าไม่น่าเกลียดเท่าไหร่ นั่นขึ้นอยู่กับคุณ


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

declare const groupBy:
  <T extends Record<string, PropertyKey>, K extends keyof T>(
    objArr: readonly T[],
    property: K,
  ) => Record<T[K], T[]>

นี่คล้ายกับเวอร์ชันของคุณ (ยกเว้น readonly T[] ซึ่งยืดหยุ่นมากกว่า T[] และการเปลี่ยนชื่อจาก U เป็น K ที่เป็นที่นิยมมากกว่าสำหรับ "key") ความแตกต่างที่สำคัญที่นี่คือผมได้เปลี่ยนประเภทของค่า T จาก unknown เป็น PropertyKey ซึ่งเป็นประเภทที่กำหนดโดยไลบรารีมาตรฐานเทียบเท่ากับ string | number | symbol ซึ่งเป็นประเภทที่สามารถใช้เป็นคีย์ได้ ดังนั้นตอนนี้คอมไพเลอร์จะรู้ว่า T[K] เป็นประเภทที่คล้ายคีย์ และสามารถใช้แบบนั้นใน Record<T[K], T[]> ได้

แน่นอนว่าเราไม่ได้ต้องการจะบอกจริง ๆ ว่า ทุก property ใน T ต้องเป็นแบบ keylike; เราเพียงแค่อยากจำกัด property ที่ key K ให้เป็นชนิดนั้น ดังนั้นเราสามารถเขียนลายเซ็นการเรียกใหม่ได้ดังนี้:

declare const groupBy:
  <T extends Record<K, PropertyKey>, K extends keyof T>(
    objArr: readonly T[],
    property: K,
  ) => Record<T[K], T[]>

ตอนนี้ T ขึ้นอยู่กับ K เราสามารถผ่อนปรนข้อจำกัดที่ว่า K extends keyof T เป็นเพียง K extends PropertyKey เนื่องจากข้อจำกัดใน T ตอนนี้รับประกันว่า K เป็นคีย์ แต่เรื่องนั้นขึ้นอยู่กับคุณ


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

const resultOrig = groupBy(data, 'group');
/* const resultOrig: Record<"item" | "animal" | "car", ({
    readonly name: "corn cob";
    readonly value: 17;
    readonly group: "item";
} | {
    readonly name: "Dirty toilet";
    readonly value: 6;
    readonly group: "item";
} | {
    readonly name: "snake";
    readonly value: 2;
    readonly group: "animal";
} | {
    readonly name: "tesla";
    readonly value: 17;
    readonly group: "car";
} | {
    readonly name: "gurgel";
    readonly value: 23;
    readonly group: "car";
})[]> */

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

type WidenLiteral<T> = 
  T extends string ? string : 
  T extends number ? number : 
  T extends boolean ? boolean : 
  T;

declare const groupBy: <T extends Record<K, PropertyKey>, K extends PropertyKey>(
   objArr: readonly T[], property: K) => Record<T[K], { 
     [P in PropertyKey & keyof T]: WidenLiteral<T[P]>; 
 }[]> */

สิ่งที่ผมทำที่นั่นคือเขียนชนิด WidenLiteral<T> ซึ่งจะเปลี่ยนชนิดหนึ่งให้เป็นเวอร์ชันที่กว้างขึ้น โดยที่ชนิดลิตเติ้ล string, number, หรือ boolean จะถูกกว้างขึ้นเป็น string, number, และ boolean ตามลำดับ ดังนั้น WidenLiteral<"a" | Date> จะกลายเป็น string | Date และ WidenLiteral<1 | 2 | 3 | 4> จะกลายเป็น number

แล้ว แทนที่จะส่งกลับอาร์เรย์ของ T[] ผมส่งกลับอาร์เรย์ {[P in PropertyKey & keyof T]: WidenLiteral<T[P]>} สิ่งที่สิ่งนี้ทำคือเอาประเภท T และถ้ามันเป็นยูเนียน มันจะรวมเข้าด้วยกันเป็นประเภทเดียว (และลืมคุณสมบัติใด ๆ ที่ไม่มีในสมาชิกใด ๆ ของยูเนียน) จากนั้นมันจะขยายคุณสมบัติลิเทอรัลด้วย WidenLiteral ดังนั้นถ้าอินพุตคือ {a: 0, b: "a", c: true} | {a: 1, b: "b", d: false} ผลลัพธ์คือ {a: number, b: string}

โปรดทราบว่าสิ่งนี้จะเกิดขึ้นโดยอัตโนมัติเมื่อประเภทที่แมปกับ T ไม่เป็นโฮโมมอร์ฟิค ดังนั้น {[P in keyof T]: 0} เป็นโฮโมมอร์ฟิคและจะเปลี่ยน {a: 1, c: 1} | {a: 2, d: 2} เป็น {a: 0, c: 0} | {a: 0, d: 0} แต่ {[P in (PropertyKey & keyof T)]: 0} ไม่เป็นโฮโมมอร์ฟิคและจะเปลี่ยน {a: 1, c: 1} | {a: 2, d: 2} เป็น {a: 0}.

อย่างไรก็ตาม ลายเซ็นของการเรียกนี้จะเปลี่ยนความยุ่งเหยิงของประเภทของ data ให้กลายเป็นสิ่งที่น่าพอใจมากขึ้น:

const result = groupBy(data, 'group');

/* const result: Record<"item" | "animal" | "car", {
    group: string;
    name: string;
    value: number;
}[]> */

เยี่ยม!


โอ้ แต่แน่นอนว่าการใช้งานของ groupBy จะต้องมีการยืนยันประเภทอย่างน้อยหนึ่งครั้ง:

const groupBy = <T extends Record<K, PropertyKey>, K extends PropertyKey>(
  objArr: readonly T[],
  property: K,
) => objArr
  .reduce((memo, x) => {
    const value = x[property];
    if (!memo[value]) {
      memo[value] = [];
    }
    memo[value].push(x as any); // <-- here
    return memo;
  }, {} as Record<T[K], { [P in (PropertyKey & keyof T)]: WidenLiteral<T[P]> }[]>);

ที่นั่นจำเป็นต้องมีการยืนยันประเภทเนื่องจากแม้ว่า x จะชัดเจนว่าเป็นค่าของประเภท T แต่ก็ไม่ชัดเจนว่าคือค่าของประเภท {[P in ...]: WidenLiteral<...>} สำหรับคอมไพเลอร์อย่างน้อย คอมไพเลอร์ไม่ถนัดการให้เหตุผลลำดับสูงเกี่ยวกับประเภททั่วไปที่ยังไม่ได้ระบุเหมือน T ภายในร่างกายของ groupBy

ดังนั้นนี่จึงปลอดภัยต่อประเภทน้อยลงเล็กน้อยจากมุมมองของผู้ใช้งาน และคุณต้องมั่นใจว่าคุณเขียนมันถูกต้อง คอมไพเลอร์ไม่สามารถจับข้อผิดพลาดทั้งหมดด้วยการยืนยันประเภทได้จริง ๆ ดังนั้น push(x as any) และ push(123 as any) จึงดูเหมือนกันต่อคอมไพเลอร์

แต่จากมุมมองของผู้โทร ควรจะใช้งานได้ดี (ตราบใดที่ผู้โทรใช้งานชนิดเฉพาะเจาะจงและไม่ใช่ชนิดทั่วไปที่ไม่ได้ระบุ)

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