ข้ามไปยังเนื้อหา

The Utility Types

TypeScript มี standard library ของ utility type มาให้ ถึงตอนนี้คุณเห็นทุกเทคนิคที่ใช้แล้ว — แทนที่จะท่องจำ คุณจึง อ่าน utility เหล่านี้ออก นั่นคือเป้าหมายของบทเรียนนี้: จำแต่ละ utility ได้และรู้ primitive เบื้องหลัง

Utilityทำอะไรสร้างอย่างไร
Partial<T>ทุก property เป็น optionalmapped type กับ ?
Required<T>ทุก property requiredmapped type กับ -?
Readonly<T>ทุก property readonlymapped type กับ readonly
Pick<T, K>เก็บเฉพาะ key Kmapped type บน K + T[K]
Omit<T, K>drop key KPick ของ Exclude<keyof T, K>
Record<K, V>object ที่ key เป็น K, value เป็น Vmapped type บน K
Exclude<T, U>ลบ U ออกจาก union Tdistributive conditional
Extract<T, U>เก็บเฉพาะ U จาก union Tdistributive conditional
NonNullable<T>ลบ null/undefinedintersection trick (T & {}) — ดูด้านล่าง
ReturnType<F>return type ของ Fconditional กับ infer
Parameters<F>tuple ของ parameter ของ Fconditional กับ infer
Awaited<T>unwrap Promiserecursive 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 เลย

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 เอง)

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 ดูเหมือนจะอยู่ตระกูลข้างบน แต่ตั้งแต่ TypeScript 4.8 นิยามจริงใน lib เป็น intersection ธรรมดา ไม่ใช่ conditional type:

type MyNonNullable<T> = T & {};
type A = MyNonNullable<string | null | undefined>; // string
type B = MyNonNullable<number | null>; // number

null กับ 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 ใช้จริง)

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>; // number
type 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 ที่ซ้อนกัน

ตารางข้างบนคือชุดคลาสสิก มีตัวที่เพิ่มมาทีหลังอีกไม่กี่ตัวที่ใช้ primitive ชุดเดิมเป๊ะ — หรือเป็น intrinsic ของ compiler ที่ไม่มีนิยามระดับ TypeScript เลย:

  • Awaited<T> (4.5) — recursive conditional ที่ใช้ infer บน PromiseLike และเป็นตัวที่ compiler ใช้กำหนด type ของ await ด้วย
  • Uppercase / Lowercase / Capitalize / Uncapitalize — intrinsic ที่พูดถึงใน Template Literal Types
  • NoInfer<T> (5.4) — intrinsic ที่กันไม่ให้ตำแหน่งนั้นมีส่วนใน inference เพื่อให้ argument ตัวหนึ่งถูก เช็คกับ type parameter โดยไม่ ขยาย มัน รายละเอียดอยู่ใน Inference and infer
// @expect-error TS2322
function 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>ตระกูล inferinfer ที่ตำแหน่ง return ของ constructor signature: T extends abstract new (...args: any) => infer R ? R : never
ConstructorParameters<T>ตระกูล inferinfer บน parameter list ทั้งหมดของ constructor สะท้อน Parameters แต่สำหรับ new (...) แทน (...)
ThisParameterType<T>ตระกูล inferinfer บน explicit this parameter: T extends (this: infer U, ...args: any) => any ? U : unknown
OmitThisParameter<T>ตระกูล inferinfer บนทุกอย่างยกเว้น 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 / Uncapitalizecompiler intrinsicไม่มีนิยามระดับ TypeScript เลย ไม่ว่าเวอร์ชันไหน

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

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 layers
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>>;
// 2. InstanceType -- infer the instance type from a constructor's return position
type 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 constructor
type 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 unknown
type 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 type
type 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 intrinsic
type 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<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 vanish
type 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” ที่ตั้งใจไว้แต่แรก

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 fine
const p2: P = { a: undefined }; // explicit undefined -- fine WITHOUT the flag, TS2375 WITH it
$ node_modules/typescript/bin/tsc --noEmit --strict --exactOptionalPropertyTypes exact-optional.ts
error 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.ts
error 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 kind
type OmitKind = Omit<Shape, "kind">;
type _footgun = Expect<Equal<OmitKind, {}>>;
// fix: distribute Omit over each union member first
type 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 }>>;
Open in TS Playground
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"]
แต่ละ utility map ไปยัง primitive ที่คุณรู้แล้ว
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 แม่นยำ"]
A footgun-free Omit and a flag-aware Required both come from reading the mechanism, not the name

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 ไม่ใช่เพราะทริกมันผิด
  • 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 และอ่านได้จริง

Partial<T> ถูก implement อย่างไร?
เทคนิคใดที่เป็นพลังของ Exclude<T, U>?
ReturnType<F> ดึง return type อย่างไร?
Omit<T, K> มักถูก define อย่างไร?
R คืออะไร และทำไม?
interface Circle { kind: "circle"; radius: number; }
interface Square { kind: "square"; side: number; }
type Shape = Circle | Square;
type R = Omit<Shape, "kind">;
อะไรแก้กับดัก Omit บน union ได้ และทางแก้ทำงานยังไง?
AType เป็น number หรือ number | undefined และขึ้นอยู่กับอะไร?
type Loose = { a?: number | undefined };
type R = Required<Loose>;
type AType = R["a"];