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