The Utility Types
คุณรู้อยู่แล้วว่าพวกนี้ถูกสร้างยังไง
หัวข้อที่มีชื่อว่า “คุณรู้อยู่แล้วว่าพวกนี้ถูกสร้างยังไง”TypeScript มี standard library ของ utility type มาให้ ถึงตอนนี้คุณเห็นทุกเทคนิคที่ใช้แล้ว — แทนที่จะท่องจำ คุณจึง อ่าน utility เหล่านี้ออก นั่นคือเป้าหมายของบทเรียนนี้: จำแต่ละ utility ได้และรู้ primitive เบื้องหลัง
| Utility | ทำอะไร | สร้างอย่างไร |
|---|---|---|
Partial<T> | ทุก property เป็น optional | mapped type กับ ? |
Required<T> | ทุก property required | mapped type กับ -? |
Readonly<T> | ทุก property readonly | mapped type กับ readonly |
Pick<T, K> | เก็บเฉพาะ key K | mapped type บน K + T[K] |
Omit<T, K> | drop key K | Pick ของ Exclude<keyof T, K> |
Record<K, V> | object ที่ key เป็น K, value เป็น V | mapped type บน K |
Exclude<T, U> | ลบ U ออกจาก union T | distributive conditional |
Extract<T, U> | เก็บเฉพาะ U จาก union T | distributive conditional |
NonNullable<T> | ลบ null/undefined | intersection trick (T & {}) — ดูด้านล่าง |
ReturnType<F> | return type ของ F | conditional กับ infer |
Parameters<F> | tuple ของ parameter ของ F | conditional กับ infer |
Awaited<T> | unwrap Promise | recursive conditional กับ infer |
ไม่ใช่ทุกตัวที่เป็น primitive จริง ๆ ทั้งสาม (mapped type, distributive conditional, conditional กับ infer):
| Utility | ทำไมไม่ใช่ primitive |
|---|---|
NonNullable<T> | เป็น intersection trick (T & {}) ไม่ใช่ conditional type — ดูด้านล่าง |
Uppercase / Lowercase / Capitalize / Uncapitalize | เป็น compiler intrinsic ที่ไม่มีนิยามระดับ TypeScript เลย |
ตระกูล mapped-type
หัวข้อที่มีชื่อว่า “ตระกูล mapped-type”Partial, Required, Readonly, Pick และ Record ล้วนเป็น mapped type บรรทัดเดียว:
type MyPartial<T> = { [K in keyof T]?: T[K] };type MyRequired<T> = { [K in keyof T]-?: T[K] };type MyReadonly<T> = { readonly [K in keyof T]: T[K] };type MyPick<T, K extends keyof T> = { [P in K]: T[P] };type MyRecord<K extends keyof any, V> = { [P in K]: V };ไม่มีอะไรพิสดาร — loop บน key บางทีเปลี่ยน modifier บางที lookup T[K] พอเห็นแบบนี้ Pick<User, "id" | "name"> ก็ชัดว่าคือ “วนซ้ำสอง key นั้น copy value type ของตัวเอง” (lib จริงชื่อ Partial, Required ฯลฯ — ที่เปลี่ยนเป็น My… ในนี้เพื่อให้ fence นี้ compile เดี่ยว ๆ ได้โดยไม่ชนกับ global ของ TypeScript เอง)
ตระกูล distributive-conditional
หัวข้อที่มีชื่อว่า “ตระกูล distributive-conditional”Exclude และ Extract ทำงานบน union ด้วย conditional type ที่ distribute บนแต่ละ member:
type MyExclude<T, U> = T extends U ? never : T;type MyExtract<T, U> = T extends U ? T : never;
type A = MyExclude<"a" | "b" | "c", "b">; // "a" | "c"เมื่อ type ที่ถูกเช็คเป็น naked type parameter และคุณส่ง union เข้าไป conditional จะรัน ทีละ member แล้ว union ผลลัพธ์ ดังนั้น Exclude map แต่ละ member เป็น never (drop) หรือตัวเอง (เก็บ) — และ never หายไปจาก union Omit จึงตกมาเป็น Pick<T, Exclude<keyof T, K>>
NonNullable: intersection trick ไม่ใช่ conditional
หัวข้อที่มีชื่อว่า “NonNullable: intersection trick ไม่ใช่ conditional”NonNullable ดูเหมือนจะอยู่ตระกูลข้างบน แต่ตั้งแต่ TypeScript 4.8 นิยามจริงใน lib เป็น intersection ธรรมดา ไม่ใช่ conditional type:
type MyNonNullable<T> = T & {};
type A = MyNonNullable<string | null | undefined>; // stringtype B = MyNonNullable<number | null>; // numbernull กับ undefined ไม่ assignable เข้า empty object type {} ดังนั้น intersect แต่ละ member ของ union กับ {} จะตัดสอง member นี้ทิ้ง ส่วน member อื่นแทบไม่เปลี่ยน — intersection distribute บน union เหมือนการคูณ distribute บนการบวก มันอ่านเหมือน “ลบ null/undefined” แต่กลไกคือ intersection ไม่ใช่ T extends null | undefined ? never : T (ซึ่งก็ใช้ได้เหมือนกัน แต่ไม่ใช่สิ่งที่ standard library ใช้จริง)
ตระกูล infer
หัวข้อที่มีชื่อว่า “ตระกูล infer”ReturnType, Parameters และ Awaited capture type ออกมาจาก structure ด้วย infer:
type MyReturnType<F> = F extends (...args: any) => infer R ? R : any;type MyParameters<F> = F extends (...args: infer P) => any ? P : never;
type R = MyReturnType<() => number>; // numbertype P = MyParameters<(a: string, b: number) => void>; // [string, number]infer R match ตำแหน่ง return ของ function แล้ว bind เข้า R; infer P match parameter list ทั้งหมดเป็น tuple สังเกตว่า fallback ของสองตัวต่างกัน: ReturnType fallback เป็น any เมื่อ F ไม่ใช่ function (เรียกผลลัพธ์แล้วไม่เพิ่ม error ใหม่) ส่วน Parameters fallback เป็น never Awaited เพิ่ม recursion เพื่อลอก promise ที่ซ้อนกัน
built-in รุ่นใหม่ที่ควรรู้
หัวข้อที่มีชื่อว่า “built-in รุ่นใหม่ที่ควรรู้”ตารางข้างบนคือชุดคลาสสิก มีตัวที่เพิ่มมาทีหลังอีกไม่กี่ตัวที่ใช้ primitive ชุดเดิมเป๊ะ — หรือเป็น intrinsic ของ compiler ที่ไม่มีนิยามระดับ TypeScript เลย:
Awaited<T>(4.5) — recursive conditional ที่ใช้inferบนPromiseLikeและเป็นตัวที่ compiler ใช้กำหนด type ของawaitด้วยUppercase/Lowercase/Capitalize/Uncapitalize— intrinsic ที่พูดถึงใน Template Literal TypesNoInfer<T>(5.4) — intrinsic ที่กันไม่ให้ตำแหน่งนั้นมีส่วนใน inference เพื่อให้ argument ตัวหนึ่งถูก เช็คกับ type parameter โดยไม่ ขยาย มัน รายละเอียดอยู่ใน Inference and infer
// @expect-error TS2322function createFSM<S extends string>(config: { states: S[]; initial: NoInfer<S> }) {}
createFSM({ states: ["idle", "busy"], initial: "idle" }); // ✅ S = "idle" | "busy"createFSM({ states: ["idle", "busy"], initial: "bogus" }); // ❌ "bogus" is not assignable to "idle" | "busy"ถ้าไม่มี NoInfer initial: "bogus" จะแค่ขยาย S เป็น "idle" | "busy" | "bogus" แล้ว compile ผ่านเฉย ๆ
built-in อีกไม่กี่ตัวเติมภาพ intrinsic-vs-primitive เดียวกันให้ครบ:
| Utility | หมวด | สร้างอย่างไร |
|---|---|---|
Awaited<T> | ตระกูล infer (recursive) | recursive conditional ลอกชั้น { then(onfulfilled: infer F) } จนกว่า value จะไม่ thenable แล้ว |
InstanceType<T> | ตระกูล infer | infer ที่ตำแหน่ง return ของ constructor signature: T extends abstract new (...args: any) => infer R ? R : never |
ConstructorParameters<T> | ตระกูล infer | infer บน parameter list ทั้งหมดของ constructor สะท้อน Parameters แต่สำหรับ new (...) แทน (...) |
ThisParameterType<T> | ตระกูล infer | infer บน explicit this parameter: T extends (this: infer U, ...args: any) => any ? U : unknown |
OmitThisParameter<T> | ตระกูล infer | infer บนทุกอย่างยกเว้น this สร้าง callable type ใหม่โดยไม่มีมัน |
NoInfer<T> (5.4) | compiler intrinsic (เคยมีทางแก้ก่อน 5.4) | ไม่มีนิยาม TypeScript ก่อน 5.4 ที่เป็น built-in จริง แต่ [T][T extends any ? 0 : never] คือทริกที่ community รู้จักดี — ดูด้านล่าง |
Uppercase / Lowercase / Capitalize / Uncapitalize | compiler intrinsic | ไม่มีนิยามระดับ TypeScript เลย ไม่ว่าเวอร์ชันไหน |
เขียนเอง 6 ตัว พิสูจน์ด้วย Equal
หัวข้อที่มีชื่อว่า “เขียนเอง 6 ตัว พิสูจน์ด้วย Equal”อ่านตารางคืออย่างหนึ่ง เขียนเวอร์ชันของตัวเองแล้วพิสูจน์ว่าตรงกันคือสิ่งที่ยืนยันจริง ๆ ว่าคุณเข้าใจกลไก ไม่ใช่แค่จำชื่อได้:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
// 1. Awaited -- recursive conditional peeling nested Promise/PromiseLike layerstype MyAwaited<T> = T extends null | undefined ? T : T extends { then(onfulfilled: infer F): any } ? F extends (value: infer V, ...args: any) => any ? MyAwaited<V> : never : T;
type A1 = MyAwaited<Promise<Promise<number>>>;type _a1 = Expect<Equal<A1, number>>;
// 2. InstanceType -- infer the instance type from a constructor's return positiontype MyInstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : never;
class Point { constructor(public x: number, public y: number) {} }type P1 = MyInstanceType<typeof Point>;type _p1 = Expect<Equal<P1, Point>>;
// 3. ConstructorParameters -- infer the parameter tuple from a constructortype MyConstructorParameters<T extends abstract new (...args: any) => any> = T extends abstract new (...args: infer P) => any ? P : never;
type CP1 = MyConstructorParameters<typeof Point>;type _cp1 = Expect<Equal<CP1, [x: number, y: number]>>;
// 4. ThisParameterType -- infer an explicit `this` parameter's type, or unknowntype MyThisParameterType<T> = T extends (this: infer U, ...args: any) => any ? U : unknown;
function greet(this: { name: string }, greeting: string) { return `${greeting}, ${this.name}`; }type G1 = MyThisParameterType<typeof greet>;type _g1 = Expect<Equal<G1, { name: string }>>;
// 5. OmitThisParameter -- strip an explicit `this` parameter from a function typetype MyOmitThisParameter<T> = T extends (this: any, ...args: infer A) => infer R ? (...args: A) => R : T;
type O1 = MyOmitThisParameter<typeof greet>;type _o1 = Expect<Equal<O1, (greeting: string) => string>>;
// 6. NoInfer -- the pre-5.4 community trick, before it became a compiler intrinsictype MyNoInfer<T> = [T][T extends any ? 0 : never];
function createFSM<S extends string>(config: { states: S[]; initial: MyNoInfer<S> }) {}createFSM({ states: ["idle", "busy"], initial: "idle" });ตัวที่หกควรหยุดดูเป็นพิเศษ: ก่อน NoInfer<T> จะกลายเป็น compiler intrinsic จริงใน TypeScript 5.4 [T][T extends any ? 0 : never] คือทางแก้มาตรฐานของ community และมันทำงานเหมือนกันเป๊ะสำหรับจุดประสงค์นี้ — ห่อ T ด้วย tuple แล้ว index กลับเข้าไปทันทีผ่าน conditional บังคับให้ตำแหน่งนั้นถูก เช็คกับ แทนที่จะ มีส่วนใน inference ผลเดียวกับที่ intrinsic จริงมี รู้จักทริกเก่านี้ไม่ใช่แค่ trivia ประวัติศาสตร์ — มันอธิบายว่าทำไม NoInfer ถึงต้องกลายเป็น intrinsic จริงเลยด้วยซ้ำ (ทริก tuple-indexing ใช้ได้ แต่บดบัง intent และไม่ compose ได้สะอาดเท่า utility จริงที่มีชื่อบอก)
กับดัก Omit บน union และทางแก้แบบ distributive
หัวข้อที่มีชื่อว่า “กับดัก Omit บน union และทางแก้แบบ distributive”Omit<T, K> นิยามเป็น Pick<T, Exclude<keyof T, K>> — และ keyof บน union ของ object type คือintersectionของ key แต่ละตัว ไม่ใช่ union ของมัน (มีแค่ key ที่มีอยู่ในทุก member เท่านั้นที่นับ) ใช้ Omit กับ union ของ object type ที่ shape ต่างกัน แล้วคุณเสีย key ได้มากกว่าตัวเดียวที่ขอให้ลบ:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
interface Circle { kind: "circle"; radius: number; }interface Square { kind: "square"; side: number; }type Shape = Circle | Square;
// Footgun: Omit<Shape, "kind"> collapses to the COMMON shape -- radius and side both vanishtype OmitKind = Omit<Shape, "kind">;// ^? {}
type _footgun = Expect<Equal<OmitKind, {}>>;kind คือ key เดียวที่ Circle กับ Square มีร่วมกัน ดังนั้น keyof Shape คือแค่ "kind" และการลบมันทิ้งเหลือ keyof Shape เป็น never — Omit<Shape, "kind"> กลายเป็น empty object type {} ทิ้ง radius กับ side ไปเลยแบบเงียบ ๆ ไม่ใช่แค่ kind ทางแก้คือ distributive-conditional idiom เดียวกับบท Conditional Types ใช้ทำให้ Omit รันครั้งเดียวต่อ union member แทนที่จะรันครั้งเดียวสำหรับ union ทั้งก้อน:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
interface Circle { kind: "circle"; radius: number; }interface Square { kind: "square"; side: number; }type Shape = Circle | Square;
type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never;
type FixedOmitKind = DistributiveOmit<Shape, "kind">;// ^? { radius: number } | { side: number }
type _fixed = Expect<Equal<FixedOmitKind, { radius: number } | { side: number }>>;T extends any ? Omit<T, K> : never ดูเหมือน conditional ที่ไม่ทำอะไรเลย (ทุกอย่าง extends any) แต่หน้าที่จริงของมันคือทำให้ T naked อยู่ฝั่งที่ถูกตรวจ ซึ่งกระตุ้น distribution: Omit ตอนนี้รันแยกกันบน Circle และ Square แต่ละตัวเก็บ property ที่ไม่ใช่ kind ของตัวเอง แล้ว union ผลลัพธ์กลับเข้าด้วยกัน — ตรงตาม “ลบ kind ออกจากแต่ละ shape” ที่ตั้งใจไว้แต่แรก
Partial กับ Required ภายใต้ exactOptionalPropertyTypes
หัวข้อที่มีชื่อว่า “Partial กับ Required ภายใต้ exactOptionalPropertyTypes”flag exactOptionalPropertyTypes เปลี่ยนความหมายที่ optional property (?) ยอมให้เป็นได้ และ Partial ได้รับผลกระทบตรง ๆ — object ที่มี value เป็น undefined อย่างชัดเจนเป็นคำถามคนละแบบกับ object ที่แค่ omit key และ flag เป็นตัวตัดสินว่า Partial ยอมรับแบบแรกไหม:
interface Base { a: number; }type P = Partial<Base>;
const p1: P = {}; // omit the key entirely -- always fineconst p2: P = { a: undefined }; // explicit undefined -- fine WITHOUT the flag, TS2375 WITH it$ node_modules/typescript/bin/tsc --noEmit --strict --exactOptionalPropertyTypes exact-optional.tserror TS2375: Type '{ a: undefined; }' is not assignable to type 'Partial<Base>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties. Types of property 'a' are incompatible. Type 'undefined' is not assignable to type 'number'.ถ้าไม่มี flag optional property มักถูกเข้าใจแบบไม่เป็นทางการมาตลอดว่า “มีอยู่ด้วย type นี้ หรือไม่มีเลย หรือมีค่าเป็น undefined ชัดเจน — ปฏิบัติเหมือนกันหมด” exactOptionalPropertyTypes ทำให้ Partial<Base>’s a?: number หมายความตรงตามที่มันบอกเป๊ะ: มีอยู่เป็น number หรือ key ไม่มีอยู่เลย — การเขียน a: undefined ตรง ๆ ตอนนี้เป็น type error เว้นแต่คุณจะระบุไว้ใน type เอง (a?: number | undefined)
ข้อเท็จจริงที่เกี่ยวข้องและน่าประหลาดใจจริง ๆ เกี่ยวกับ Required ในทิศทางตรงข้าม: เมื่อ type ของ property ต้นทางมี | undefined อยู่ชัดเจน (ไม่ใช่แค่ optional โดยนัย) ว่า Required จะลบ undefined ที่ชัดเจนนั้นทิ้งไหมขึ้นอยู่กับ flag เดียวกันนี้เป๊ะ:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
type LooseExplicit = { a?: number | undefined };type RExplicit = Required<LooseExplicit>;
type AType = RExplicit["a"];type _isNumber = Expect<Equal<AType, number>>;$ node_modules/typescript/bin/tsc --noEmit --strict exact-optional3.ts# (0 errors -- AType really is `number`: the explicit `| undefined` was stripped)
$ node_modules/typescript/bin/tsc --noEmit --strict --exactOptionalPropertyTypes exact-optional3.tserror TS2344: Type 'false' does not satisfy the constraint 'true'.# (AType is `number | undefined` here -- Required did NOT strip it)ถ้าไม่มี exactOptionalPropertyTypes ? กับ | undefined ที่เขียนชัดเจนถูกมองว่าเป็นการพูดซ้ำเรื่องเดียวกัน และ -? ของ Required ยุบมันรวมกันไป เหลือแค่ number ธรรมดา มี flag แล้ว ? กับ | undefined เป็นคนละเรื่องกันโดยตั้งใจ (การแยกนี้แหละคือประเด็นทั้งหมดของ flag) ดังนั้น -? ลบแค่ optionality — | undefined ที่ชัดเจนใน value type รอดไปโดยไม่ถูกแตะ และ RExplicit["a"] ยังคงเป็น number | undefined นี่คือ claim แบบที่ดูชัดเจนไปได้ทั้งสองทิศทางจนกว่าคุณจะรันทั้งสอง command จริง ๆ — ซึ่งเป็นเหตุผลที่ทั้งคู่ถูก quote ไว้ข้างบนแทนที่จะยืนยันจากความจำ
Six hand-written utilities, the Omit-on-union footgun, and its distributive fix
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
type MyAwaited<T> = T extends null | undefined ? T : T extends { then(onfulfilled: infer F): any } ? F extends (value: infer V, ...args: any) => any ? MyAwaited<V> : never : T;type A1 = MyAwaited<Promise<Promise<number>>>;type _a1 = Expect<Equal<A1, number>>;
interface Circle { kind: "circle"; radius: number; }interface Square { kind: "square"; side: number; }type Shape = Circle | Square;
// footgun: loses radius AND side, not just kindtype OmitKind = Omit<Shape, "kind">;type _footgun = Expect<Equal<OmitKind, {}>>;
// fix: distribute Omit over each union member firsttype DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never;type FixedOmitKind = DistributiveOmit<Shape, "kind">;type _fixed = Expect<Equal<FixedOmitKind, { radius: number } | { side: number }>>; flowchart TB
mapped["Mapped types"] --> f1["Partial, Required, Readonly, Pick, Record"]
cond["Distributive conditionals"] --> f2["Exclude, Extract, Omit"]
infer["Conditionals with infer"] --> f3["ReturnType, Parameters, Awaited"]
trick["Intersection trick (T & {})"] --> f4["NonNullable"] Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart TD omitUnion["Omit ใช้กับ union ของ object type"] --> keyofUnion["keyof (A | B) คือ INTERSECTION ของ key -- นับแค่ key ที่ร่วมกัน"] keyofUnion --> lostKeys["ลบ key ที่ร่วมกันอาจดึง key อื่นที่ไม่เกี่ยวข้องหายไปด้วย"] distribute["T extends any ? Omit<T, K> : never"] --> perMember["รัน Omit ครั้งเดียวต่อ union member แทน"] flagOff["exactOptionalPropertyTypes ปิด"] --> loose["? กับ | undefined ที่ชัดเจนถูกมองว่าเป็นไอเดียเดียวกัน"] flagOn["exactOptionalPropertyTypes เปิด"] --> strict["? กับ | undefined ที่ชัดเจนแยกกัน ทั้งคู่ถูก track แม่นยำ"]
utility type ทุกตัวในบทนี้ยุบเหลือกลไกจำนวนน้อยที่คุณรู้จักอยู่แล้วจากบทก่อนหน้า — mapped type, distributive conditional, infer capture, intersection trick หรือ (สำหรับ intrinsic string 4 ตัว) ไม่มี TypeScript ที่เขียนได้เลย สอง “กับดัก” ในบทนี้ — Omit บน union และการจัดการ | undefined ที่ขึ้นกับ flag ของ Required — ไม่ใช่กลไกใหม่ มันคือกลไกเดิมที่โผล่มาในที่ที่ไม่ชัดเจนเท่าบทที่แนะนำมันครั้งแรก (distribution และความต่างระหว่าง modifier กับ value type)
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
Omit<T, K> ธรรมดาบน union | ง่าย และถูกต้องสำหรับ object type เดี่ยว หรือ union ที่ member แชร์แค่ key ที่ถูกลบโดยไม่มีอะไรอื่นเสี่ยง | ทิ้ง key ทุกตัวที่ไม่ร่วมกันในทุก union member แบบเงียบ ๆ ทันทีที่คุณลบ key ที่เคยร่วมกัน — verify ด้วย Equal ก่อนเชื่อมันบน union |
DistributiveOmit<T, K> | เก็บ key ของแต่ละ union member ไว้ถูกต้อง ลบแค่ K จากแต่ละตัว | ต้องนิยาม type alias เพิ่มและจำไว้ว่าต้องใช้เมื่อ T อาจเป็น union |
Partial<T> ภายใต้ exactOptionalPropertyTypes | ตรงกับ premise ทั้งหมดของ flag: optional แปลว่า “มีอยู่หรือไม่มีเลย” จริง ๆ ไม่ใช่ “มีอยู่ ไม่มีเลย หรือมี undefined ชัดเจน” | โค้ดเก่าที่เคยเขียน { ...defaults, field: undefined } เพื่อ “reset” field ต้องเพิ่ม ` |
เขียน utility เองแล้วพิสูจน์ด้วย Equal | ยืนยันความเข้าใจที่ลึกกว่า “จำชื่อนี้ได้” | ใช้เวลานานกว่าเชื่อ built-in — คุ้มค่าทำครั้งเดียวต่อตระกูล utility ไม่ต้องทำทุกตัวที่ใช้ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”Omit<T, K>ทำตามชื่อมันเสมอ: ลบKเก็บที่เหลือไว้ทั้งหมด บน union ของ object type มันลบได้มากกว่าKมาก — อะไรก็ตามที่ไม่ร่วมกันในทุก member หายไปด้วย เพราะOmitสร้างจากkeyofและkeyofบน union คือ intersection ของ key ไม่ใช่ union ของมันRequired<T>ลบundefinedออกจาก type ของ property เสมอ เหมือนที่มันลบ modifier?ขึ้นอยู่กับexactOptionalPropertyTypesถ้าไม่มี flag| undefinedที่ผู้เขียนเขียนชัดเจนถูกมองว่าซ้ำกับ?แล้วถูกยุบทิ้งไป ถ้ามี flag?กับ| undefinedแยกกัน และRequiredลบแค่ modifier —| undefinedที่ชัดเจนยังอยู่- utility ที่ใช้กับ
Tก็เหมือน utility เดียวกันที่ใช้กับแต่ละ member ของTถ้าTบังเอิญเป็น union จริงแค่กับตัวที่สร้างจาก distributive conditional แบบ naked (Exclude,Extract) หรือ mapped type แบบ homomorphic บนTเปลือย ๆOmit(ผ่านkeyofบนทั้ง union) คือตัวอย่างที่ขัดแย้งชัดเจน ควรเช็คแต่ละ utility เอง ไม่ใช่สมมติ - ทริก
NoInferก่อน 5.4 ([T][T extends any ? 0 : never]) เป็นแค่การเลียนแบบที่ด้อยกว่าและพฤติกรรมต่างจากตัวจริง สำหรับจุดประสงค์ที่มันมีอยู่ — กันไม่ให้ตำแหน่งมีส่วนใน inference — มันทำงานเหมือนกันเป๊ะ มันกลายเป็น intrinsic จริงเพื่อความชัดเจนและ composability ไม่ใช่เพราะทริกมันผิด
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”Omit<SomeUnion, "someKey">ในโค้ด generated ที่ model discriminated union (API response variant, form state, event payload) ควรเช็คทันที:keyofของSomeUnionมีมากกว่า discriminant จริงไหม? ถ้า union member ไม่แชร์ key อื่นกันเลยOmitเกือบแน่นอนให้{}หรืออะไรที่แคบเกินไปแบบเงียบ ๆ และโค้ด generated ที่ compile ผ่านไม่ใช่หลักฐานว่ามันถูกต้อง — verify ด้วยEqualเทียบกับสิ่งที่คุณคาดหวัง ไม่ใช่อ่านจาก call site- โค้ด generated ที่ assign
field: undefinedเพื่อ “clear” optional field ใน codebase ที่เปิดexactOptionalPropertyTypesจะ compile ไม่ผ่าน — แต่โค้ดที่ generate โดยไม่รู้ว่า flag นี้เปิดอยู่อาจหันไปใช้delete obj.fieldหรือ cast เพื่อหลบ error แทน ซึ่งควร flag ว่าเป็น workaround ไม่ใช่ทางแก้จริง วิธีที่ type-correct คือเพิ่ม| undefinedเข้า type ของ field อย่างตั้งใจ หรือ omit key จริง ๆ - utility type ที่เขียนมือแล้วอ้างว่าตรงกับ built-in (
MyAwaitedที่เขียนเอง, deep-utility ที่เขียนเอง) โดยไม่มีExpect<Equal<...>>อยู่ใกล้ ๆ เลย กำลังเชื่อ docstring หรือชื่อตัวแปรเป็นหลักฐาน — pattern “เขียนเองแล้วพิสูจน์” จากบทนี้คือมาตรฐานที่โค้ดระดับ type ที่ generate มาควรถูกวัดด้วยก่อน ship
🐛 หา bug ในโค้ดนี้
type ApiSuccess = { status: "ok"; data: string; };type ApiError = { status: "error"; message: string; };type ApiResponse = ApiSuccess | ApiError;
// "strip the internal status field before returning to the client"type PublicResponse = Omit<ApiResponse, "status">;
function toPublic(res: ApiResponse): PublicResponse {const { status, ...rest } = res;return rest;}เฉลย
โค้ดนี้ compile ผ่านโดยไม่มี error เลย และนั่นแหละคือกับดัก Omit<ApiResponse, "status"> คือ {} (empty object type) เพราะ status คือ key เดียวที่ ApiSuccess กับ ApiError มีร่วมกัน — ลบมันทิ้งเหลือ keyof ApiResponse เป็น never PublicResponse ควรจะแปลว่า “success data ที่ไม่มี status tag หรือ error message ที่ไม่มี status tag” ({ data: string } | { message: string }) แต่กลับแปลว่า “object ที่ไม่รับประกัน property อะไรเลย” return rest; type-check ผ่านเพราะ object อะไรก็ได้ assignable ให้ {} — function ดูเหมือนทำสิ่งที่ถูกต้อง และ type ที่มันคืนก็ type-check ผ่านที่ call site ด้วย จนกว่าคนที่เรียก toPublic จะลองอ่าน .data หรือ .message จากผลลัพธ์แล้วพบว่า type system ไม่เคยสัญญาว่าตัวไหนมีอยู่เลย ทางแก้คือ DistributiveOmit<ApiResponse, "status"> (T extends any ? Omit<T, "status"> : never) ซึ่ง resolve เป็น { data: string } | { message: string } ถูกต้อง ด้วยการรัน Omit บน ApiSuccess และ ApiError แยกกันก่อนจะ union ผลลัพธ์ — ให้ caller ของ toPublic ได้ return type ที่ narrow และอ่านได้จริง