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

typescript - ฉันจะจำกัดชนิด union ตามคีย์ ค่า และรูปแบบที่ส่งให้ฟังก์ชันอย่างไร?


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

export type DiscriminateUnionType<Map, Tag extends keyof Map, TagValue extends Map[Tag]> = Map extends Record<
  Tag,
  TagValue
>
  ? Map
  : never;

function inStateOfType<Map extends { [index in Tag]: TagValue }, Tag extends keyof Map, TagValue extends Map[Tag], DiscriminatedState extends DiscriminateUnionType<Map, Tag, TagValue>>(tag: Tag, value: TagValue, state: Map): DiscriminatedState | undefined {
  return state[tag] === value ? state as DiscriminatedState : undefined
}

type State = { type: 'loading', a: string } | { type: 'loaded', b: string } | { type: 'someOtherState', c: string }

export function main(state: State) {
  const loadedState = inStateOfType('type', 'loading', state)

  if (loadedState) {
    loadedState.b // Property 'b' does not exist on type 'State'. Property 'b' does not exist on type '{ type: "loading"; a: string; }'
  }
}

function inStateOfType<Map extends { type: string }, Tag extends 'type', TagValue extends Map[Tag], DiscriminatedState extends DiscriminateUnionType<Map, Tag, TagValue>>(state: Map, value: TagValue): DiscriminatedState | undefined {
  return state['type'] === value ? state as DiscriminatedState : undefined
}

function main(state: State) {
  // { type: "loaded"; b: string }, everything is fine, narrowing works
  // but in this case, inStateOfType function is not generic
  const loadedState = inStateOfType(state, 'loaded')

  if (loadedState) {
    loadedState.b 
  }
}

เพื่อที่จะตรวจสอบเรื่องนี้ ฉันได้สร้างสคริปต์ที่สามารถรันได้พร้อมกับโค้ด เพื่อให้คุณสามารถดีบักมันใน TS playground

คำตอบ #1

ต่อไปนี้ฉันจะเปลี่ยนชื่อของพารามิเตอร์ประเภทของคุณให้สอดคล้องกับแนวทางของ TypeScript มากขึ้น (ตัวอักษรพิมพ์ใหญ่ตัวเดียว); Map จะกลายเป็น M, Tag จะกลายเป็น K (เพราะมันเป็น key ของ M), TagValue จะกลายเป็น V, index จะกลายเป็น I, และ DiscriminatedState จะกลายเป็น S. ดังนั้นตอนนี้เราก็มี:

function inStateOfType<
  M extends { [I in K]: V },
  K extends keyof M,
  V extends M[K], 
  S extends Extract<M, Record<K, V>>
>(tag: K, value: V, state: M): S | undefined {
  return state[tag] === value ? state as S : undefined
}

และสังเกตว่า { [I in K]: V } เท่ากับ Record<K, V> โดยใช้ประเภทยูทิลิตี้ Record<K, V> และว่า

type DiscriminateUnionType<M, K extends keyof M, V extends M[K]> =
  M extends Record<K, V> ? M : never;

สามารถละเว้นได้เพื่อใช้ประโยชน์จากประเภทยูทิลิตี้ในตัว Extract<T, U> เป็น Extract<M, Record<K, V>> ดังนั้นตอนนี้เราจึงมี:

function inStateOfType<
  M extends Record<K, V>,
  K extends keyof M,
  V extends M[K], S extends Extract<M, Record<K, V>>
>(tag: K, value: V, state: M): S | undefined {
  return state[tag] === value ? state as S : undefined
}

เราเกือบจะทำความสะอาดส่วนนี้เสร็จจนสามารถตอบได้แล้ว อีกเรื่องหนึ่ง; พารามิเตอร์ประเภท S เกินความจำเป็น ไม่มีตำแหน่งการอนุมานที่ดีสำหรับมัน (ไม่มีพารามิเตอร์ใดเป็นประเภท S หรือฟังก์ชันของ S) ดังนั้นคอมไพเลอร์จะกลับไปใช้ให้ S เป็น Extract<M, Record<K, V>> ที่แน่นอน ซึ่งหมายความว่ามันเป็นเพียงคำพ้องความหมายของมัน

และถ้าคุณกำลังจะเขียน return xxx ? yyy as S : undefined คุณก็ไม่จำเป็นต้องระบุประเภทการคืนค่าเลย เพราะมันจะถูกอนุมานเป็น S | undefined

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

function inStateOfType<
  M extends Record<K, V>,
  K extends keyof M,
  V extends M[K]
>(tag: K, value: V, state: M) {
  return state[tag] === value ?
    state as Extract<M, Record<K, V>> :
    undefined
}

แล้วทำไมมันถึงใช้ไม่ได้ล่ะ? ปัญหาใหญ่ที่นี่คือ M ควรจะเป็นประเภท union แบบ discriminated เต็มรูปแบบ ดังนั้นคุณจึงไม่สามารถจำกัดมันให้เป็น Record<K, V> ได้ เพราะ V เป็นเพียงหนึ่งในค่าที่เป็นไปได้หลายค่าของคีย์ K หากคุณจำกัด M ให้เป็น Record<K, V> ตัวคอมไพเลอร์จะไม่ยอมให้คุณส่งค่าของ state เข้ามา เว้นแต่มันจะรู้แล้วว่า property tag ของมันเป็นประเภทเดียวกับ value หรือ ในกรณีของคุณ ตัวคอมไพเลอร์จะขยาย V เพื่อให้ครอบคลุมชุดทั้งหมดของความเป็นไปได้สำหรับ tag โอ๊ะ

ดังนั้นถ้าเราไม่สามารถจำกัด M ให้เป็น Record<K, V> ได้ เราควรจำกัดมันไว้ที่อะไร? มันต้องมีคีย์ที่ K แต่ชนิดค่าที่นั่นควรถูกจำกัดเฉพาะให้เป็นคุณสมบัติที่สามารถใช้เป็นตัวแยกประเภทได้เท่านั้น บางอย่างเช่น

type DiscriminantValues = string | number | boolean | null | undefined;

ลองทำมันดู:

function inStateOfGenericType<
  M extends Record<K, DiscriminantValues>,
  K extends keyof M,
  V extends M[K]
>(tag: K, value: V, state: M) {
  return state[tag] === value ?
    state as Extract<M, Record<K, V>> :
    undefined
}

function main(state: State) {
  const loadedState = inStateOfGenericType('type', 'loaded', state)

  if (loadedState) {
    loadedState.b // okay
  }
}

และนั่นก็เสร็จเรียบร้อย!


โปรดทราบว่าใน TypeScript มักจะมีความนิยมมากขึ้นในการเขียนใหม่เป็นฟังก์ชันการตรวจสอบประเภทที่ผู้ใช้กำหนดเอง ซึ่ง inStateOfType() จะคืนค่า boolean ที่สามารถใช้ตัดสินใจได้ว่า compiler อาจลด state ให้เป็น Record<K, V> หรือไม่:

function inStateOfGenericType<
  M extends Record<K, DiscriminantValues>,
  K extends keyof M,
  V extends M[K] 
>(tag: K, value: V, state: M):
  state is Extract<M, Record<K, V>> {
  return state[tag] === value
}

function main(state: State) {
  if (inStateOfGenericType('type', 'loaded', state)) {
    state.b // okay
  }
}

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

คำตอบ #2

คุณพยายามที่จะทำบางอย่างเหมือนกับการเลือกปฏิบัติแบบย้อนกลับ ซึ่งในความเป็นจริงนั้นเป็นไปไม่ได้ นี่เป็นเพราะว่า TagValue extends Map[Tag] ของคุณ ซึ่ง Tag=type ซึ่งน่าเสียดายว่าจะทำให้เกิด union เสมอ 'loaded' | 'loading' | 'someOtherValue' เมื่อคุณ extend Map[Tag] มันเหมือนเป็นการจำกัดชนิดเอง ดังนั้น TS จึงไม่จำกัดมันเพิ่มเติม เพราะ 'loaded' จะตอบสนองต่อ union นั้นและจะผ่านไปได้ แต่จากนั้นก็จะส่ง union ทั้งหมดไปยัง DiscriminatedState

ดังนั้นเมื่อมันพยายามใช้ TagValue ใน DiscriminateUnionType<> มันจะส่งทั้งยูเนียนเข้าไปและไม่จำกัดมันจริง ๆ เลย

แทนที่จะใช้ TagValue extends Map[Tag] คุณควรอนุญาตให้ใช้สตริงใดก็ได้ จากนั้นค่อยตรวจสอบเงื่อนไขทีหลัง วิธีนี้การจำกัดประเภทจะเกิดขึ้นนอก generic ซึ่ง TS อาจสรุปและจำกัดประเภทผิดพลาดได้

function inStateOfGenericTypeFix<
  Map extends Record<string, any>, 
  Tag extends keyof Map, 
  TagValue extends string = Map[Tag], 
> (tag: Tag, value: TagValue, state: Map): 
  TagValue extends Map[Tag] 
  ? DiscriminateUnionType<Map, Tag, typeof value> 
  : unknown
  | undefined 
{
  return state[tag] === value ? state as any : undefined
}

  const loadedState = inStateOfGenericTypeFix('type', 'loaded', state)
  //  ^?

  if (loadedState) {
    loadedState.b //No more error!
  }

น่าเสียดายที่นี่หมายความว่าคุณสามารถส่งสตริงใด ๆ เข้าไปใน value ได้ แต่ถ้าสตริงนั้นไม่ใช่คีย์ที่ถูกต้อง มันจะคืนค่า unknown โดยพื้นฐานแล้วบอกคุณว่าการใช้งานที่ตั้งใจไว้นั้นไม่ถูกต้อง คุณยังสามารถตั้งค่าให้คืนค่า undefined หรือค่าอื่น ๆ ตามที่คุณต้องการได้เช่นกัน

นี่คือตัวอย่างมากมาย/โค้ดบน Playground

นี่เป็นวิธีส่วนตัวของฉันในการทำการอนุมานประเภทแบบนี้ และถ้าใครมีวิธีที่ดีกว่าฉันอยากได้ยินมัน