Unions & Intersections
Union: value ที่เป็นหนึ่งในหลาย type
หัวข้อที่มีชื่อว่า “Union: value ที่เป็นหนึ่งในหลาย type”union A | B อธิบาย value ที่เป็น ไม่ A ก็ B ใน model เซตนี่คือ set union — union จึง กว้างกว่า สมาชิกของตัวเองเสมอ
type Id = string | number;
let id: Id = "abc";id = 123; // ✅ both are members of the unionจุดที่ต้องระวัง: ระหว่างที่คุณถือ union อยู่ คุณแตะได้แค่ member ที่ ทุก branch มีร่วมกัน เพราะ compiler ยังไม่รู้ว่าคุณถือตัวไหน
// @expect-error TS2339function printId(id: string | number) { id.toUpperCase(); // ❌ toUpperCase does not exist on number. // Only members common to string AND number are available here.}จะใช้ member เฉพาะ type ต้อง narrow ก่อน — เนื้อหาทั้งบทหน้า จนกว่าจะ narrow union จะเปิดให้เห็นแค่ surface ที่มีร่วมกัน
Intersection: value ที่เป็นทุก type พร้อมกัน
หัวข้อที่มีชื่อว่า “Intersection: value ที่เป็นทุก type พร้อมกัน”intersection A & B อธิบาย value ที่เป็นทั้ง A และ B พร้อมกัน สำหรับ object type นี่หมายถึง “มีทุก property ของทั้งสอง” — intersection ของ object จึง แคบกว่า (เงื่อนไขมากขึ้น value ที่ valid น้อยลง)
type WithId = { id: string };type WithTimestamps = { createdAt: Date; updatedAt: Date };
type Entity = WithId & WithTimestamps;// must have id AND createdAt AND updatedAt
const e: Entity = { id: "1", createdAt: new Date(), updatedAt: new Date() };นี่คือวิธี idiomatic ในการประกอบ object type จากชิ้นเล็ก ๆ ที่ reuse ได้ — ยืดหยุ่นกว่า inheritance มาก เพราะผสมได้ทุกแบบ
ทั้งสองดึงไปคนละทาง
หัวข้อที่มีชื่อว่า “ทั้งสองดึงไปคนละทาง”flowchart TB
subgraph un["A OR B (union)"]
u["more values allowed
fewer members guaranteed"]
end
subgraph inter["A AND B (intersection)"]
i["fewer values allowed
more members guaranteed"]
end สังเกต trade-off ของแต่ละทิศ:
- union อนุญาต value มากขึ้น แต่การันตี member ที่แตะได้อย่างปลอดภัยน้อยลง (แค่ที่มีร่วมกัน)
- intersection อนุญาต value น้อยลง แต่การันตี member มากขึ้น (ครบทุกตัว)
edge case หลุดออกมาจาก model เซตเอง
หัวข้อที่มีชื่อว่า “edge case หลุดออกมาจาก model เซตเอง”string & numberเป็นnever— ไม่มี value ไหนเป็นทั้งสอง intersection เลยเป็นเซตว่าง- union กับ
anyจะยุบเป็นany; union กับneverจะทิ้งneverไป (T | neverก็คือTเพราะการเพิ่มเซตว่างไม่เปลี่ยนอะไร) - intersect object type ที่มี property primitive ชนกัน อาจได้
neverสำหรับ property นั้น
type A = { kind: "a"; value: number };type B = { kind: "b"; value: string };type Both = A & B;// kind: "a" & "b" → never; this type is effectively unconstructableตัวสุดท้ายเป็นสัญญาณ: เวลาคุณอยากได้ “หนึ่งใน object shape พวกนี้” คุณเกือบทุกครั้งต้องการ union (A | B) ที่มี discriminant field ร่วมกัน — ไม่ใช่ intersection นั่นคือ discriminated-union pattern ในบท narrowing
Weak type: check ที่รอดจากการอ้อมผ่านตัวแปร
หัวข้อที่มีชื่อว่า “Weak type: check ที่รอดจากการอ้อมผ่านตัวแปร”object literal ได้ excess-property check แต่แค่ตอน assign แบบ fresh — ผ่านตัวแปร property ส่วนเกินมักผ่านได้ตามปกติ (บท object พูดเรื่องนี้) union ของ object type ที่มีแต่ optional property ล้วน ๆ ได้ check ที่เข้มกว่าและไม่โดนหลอกด้วยการอ้อมผ่านตัวแปร: ถ้า value ไม่มี property ร่วมกับ union member ไหนเลย มันถูกปฏิเสธแม้จะผ่านตัวแปรก็ตาม
// @expect-error TS2345interface Circle { radius?: number }interface Square { side?: number }
function paint(shape: Circle | Square) {}
const opts = { raduis: 10 }; // typo -- not a fresh literal, assigned to a variable firstpaint(opts); // ❌ still rejected -- 'opts' shares no property with Circle or Squareกฎ “weak type” นี้มีอยู่เพราะ union ของ interface ที่ optional ล้วนจะรับได้เกือบทุกอย่างถ้าไม่มีกฎนี้ — object ไหนก็ compatible กับ { radius?: number } แบบ vacuous เพราะมันไม่มี property จำเป็นเลย การบังคับให้ต้องมี property ร่วมกันอย่างน้อยหนึ่งตัว ปิดช่องโหว่นี้ และต่างจาก excess-property check ปกติตรงที่มันไม่จำกัดแค่ fresh literal
Intersect function type: รวม call signature เข้าด้วยกัน
หัวข้อที่มีชื่อว่า “Intersect function type: รวม call signature เข้าด้วยกัน”& บน function type สองตัวไม่ได้เฉลี่ยมันเข้าด้วยกัน — มันสร้าง value ที่เรียกได้ทั้งสอง signature นี่คือวิธีสร้าง function ที่ overload จาก function ธรรมดาสองตัว:
// @runtype StrFn = (x: string) => string;type NumFn = (x: number) => number;type Overloaded = StrFn & NumFn;
function impl(x: string): string;function impl(x: number): number;function impl(x: string | number): string | number { return typeof x === "string" ? x.toUpperCase() : x * 2;}
const fn: Overloaded = impl as Overloaded; // one runtime function, two call signaturesconsole.log(fn("hi")); // ✅ matches StrFnconsole.log(fn(5)); // ✅ matches NumFn// HI// 10union ของ function type สองตัวเดียวกันเข้มงวดกว่ามาก: เรียกได้ด้วย argument ที่ valid สำหรับทุก member เท่านั้น เพราะ compiler ไม่รู้ตรงจุดที่เรียกว่ามันเป็นตัวไหนจริง ๆ
// @expect-error TS2554type Logger = (msg: string) => void;type Reporter = (msg: string, code: number) => void;declare const handler: Logger | Reporter;handler("hi"); // ❌ Expected 2 arguments, but got 1 -- Reporter's signature also has to be satisfiedนี่คือความตึงระหว่าง widen/narrow แบบเดียวกับที่เจอทั้งโมดูล แค่เอามาใช้กับ callable: intersect function ทำให้เรียกได้ด้วย argument ที่กว้างขึ้น (ต้องตรงกับ signature มากขึ้น) ส่วน union ของ function ทำให้แคบลง (ปลอดภัยแค่ส่วนที่ร่วมกัน)
Intersecting vs unioning function types
type StrFn = (x: string) => string;type NumFn = (x: number) => number;type Overloaded = StrFn & NumFn; // callable as either signature
function impl(x: string): string;function impl(x: number): number;function impl(x: string | number): string | number { return typeof x === "string" ? x.toUpperCase() : x * 2;}
const fn: Overloaded = impl as Overloaded; // one runtime function, two call signaturesconsole.log(fn("hi")); // matches StrFnconsole.log(fn(5)); // matches NumFn หมายเหตุเรื่องการแสดงผลใน TypeScript 7
หัวข้อที่มีชื่อว่า “หมายเหตุเรื่องการแสดงผลใน TypeScript 7”TypeScript 7 เรียงลำดับ member ของ union แบบ deterministic (--stableTypeOrdering เปิดถาวร) hover หรือ error message จึงอาจแสดง string | number ในจุดที่ 6.0 เคยแสดง number | string เรื่อง assignability ไม่เปลี่ยนเลย — union คือเซต — แต่ test ที่ snapshot ข้อความ error หรือ output .d.ts อาจต้อง generate ใหม่
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart LR
A["Type A
(a set of values)"] --- OR{{"A | B"}}
B["Type B
(a set of values)"] --- OR
OR --> wider["Wider set
fewer guaranteed members"]
A --- AND{{"A & B"}}
B --- AND
AND --> narrower["Narrower set
more guaranteed members"] ถามคำถามเดียวกันทุกครั้งที่เจอ | หรือ &: กำลังอธิบายเซตของ value อันไหนอยู่ และแตะ member ตัวไหนได้อย่างปลอดภัยโดยไม่ต้อง narrow? union ขยายเซตของ value ที่ valid แต่ย่อพื้นที่ที่แตะได้อย่างปลอดภัยลงเหลือแค่ member ที่มีร่วมกัน ส่วน intersection ย่อเซตของ value ที่ valid แต่ขยายพื้นที่ที่การันตีได้ให้ครบทุก member ของทุกส่วน check “weak type” และพฤติกรรมของ function signature ข้างบนก็คือกฎเดียวกันนี้ที่เอาไปใช้กับรูปแบบที่ไม่ค่อยชัดเจน — union ของ object ที่มี optional property, union ของ callable
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| แนวทาง | ได้อะไร | เสียอะไร |
|---|---|---|
Union (A | B) | model “หนึ่งในหลาย shape” ตรงกับความหลากหลายของข้อมูลจริง | แตะได้แค่ member ที่มีร่วมกันจนกว่าจะ narrow |
Intersection (A & B) | ประกอบ object piece เล็ก ๆ ที่ reuse ได้โดยไม่ต้องใช้ inheritance | property primitive ที่ชนกันทำให้ intersection ยุบเป็น never |
| Intersect function type | สร้าง value ที่ overload ได้จากสอง signature | implementation จริงตอน runtime ต้อง handle ทุก signature จริง ๆ — type แค่สัญญาไว้ ไม่ได้ให้ implementation |
| Union ของ interface ที่ optional ล้วน | สะดวกเมื่อทุก field เป็น optional จริง ๆ ในทุก member | check “weak type” ต้องการแค่ property ร่วมกันตัวเดียว typo เลยหลุดผ่านได้ถ้าไปตรงกับชื่อ field อื่นพอดี |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- union ของ object type ที่มี property boolean หรือ string ร่วมกัน narrow บน property นั้นได้อัตโนมัติ ไม่จริง เว้นแต่ property นั้นเป็น literal discriminant ที่ไม่ซ้ำกันในแต่ละ member (pattern
kind: "circle"จากบท narrowing) — เช็คค่า truthy ของok: booleanที่มีร่วมกันไม่ narrow อะไรเลย - intersect object type สองตัวที่เข้ากันไม่ได้เป็น error เสมอ ไม่จริง —
A & Bที่ property primitive ชนกัน แค่ทำให้property นั้นกลายเป็นnevertype ยังมีอยู่ (แค่ field นั้นสร้างไม่ได้จริง ๆ) - union ปฏิเสธ object ที่มี property ไม่รู้จักเหมือน object type ตัวเดียว มันปฏิเสธแค่ literal แบบ fresh ที่ไม่มี property ร่วมกับ member ไหนเลย — ตัวแปรที่มีอยู่แล้ว หรือตัวที่มี property ร่วมแม้แค่ตัวเดียว ก็ผ่านไปได้เฉย ๆ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ union หรือ intersection ที่ generate มา ให้เช็คสิ่งเหล่านี้:
if (obj.someSharedBooleanProperty)ที่ใช้ narrow union ของ object type — ถ้า property นั้นไม่ใช่ literal เฉพาะแต่ละ member จะไม่ narrow อะไรเลย และการเข้าถึง property ต่อ ๆ ไปก็ยังถูกเช็คกับ union ทั้งก้อน- union parameter ที่รับ object ที่สร้างจากการ spread หรือ merge จาก source ที่ไม่มี type — check “weak type” ต้องการแค่ property ชื่อตรงกันตัวเดียวก็ปล่อยผ่านทั้ง object รวม typo ด้วย
- function ที่ type เป็น intersection ของ call signature (
FnA & FnB) แต่ implementation จริงรองรับแค่ตัวเดียว — type สัญญาว่าเรียกได้ทั้งคู่อย่างปลอดภัย แต่ไม่มีอะไรเช็คว่า body แยก branch ถูกต้องจริงตอน runtime
🐛 หา bug ในโค้ดนี้
interface SuccessResult { data?: unknown; ok?: boolean }interface ErrorResult { message?: string; ok?: boolean }
function handle(result: SuccessResult | ErrorResult) {console.log(result.ok);}
const response = { ok: true, dta: "typo -- meant 'data', but shares 'ok' with both members" };handle(response); // does the typo get caught?เฉลย
โค้ดนี้ compile ผ่านโดยไม่มี error เลย response ไม่ใช่ fresh literal argument (มันเป็นตัวแปร) excess-property check ปกติเลยไม่ทำงาน และ check “weak type” ของ union ต้องการแค่ property ร่วมกันอย่างน้อยหนึ่งตัว — ok ตอบโจทย์นั้นให้ทั้ง SuccessResult และ ErrorResult field dta ที่พิมพ์ผิดเลยลอดผ่านไปโดยไม่มีใครสังเกตเห็น สัญญาของ type system ตรงนี้อ่อนกว่าที่มันดูมาก — แค่มีชื่อ property ตรงกับ union member ตัวเดียวก็พอแล้ว ไม่ว่าที่เหลือของ object จะผิดแค่ไหน