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

Unions & Intersections

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 TS2339
function 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 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
union กว้างขึ้น intersection แคบลง

สังเกต trade-off ของแต่ละทิศ:

  • union อนุญาต value มากขึ้น แต่การันตี member ที่แตะได้อย่างปลอดภัยน้อยลง (แค่ที่มีร่วมกัน)
  • intersection อนุญาต value น้อยลง แต่การันตี member มากขึ้น (ครบทุกตัว)
  • 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

object literal ได้ excess-property check แต่แค่ตอน assign แบบ fresh — ผ่านตัวแปร property ส่วนเกินมักผ่านได้ตามปกติ (บท object พูดเรื่องนี้) union ของ object type ที่มีแต่ optional property ล้วน ๆ ได้ check ที่เข้มกว่าและไม่โดนหลอกด้วยการอ้อมผ่านตัวแปร: ถ้า value ไม่มี property ร่วมกับ union member ไหนเลย มันถูกปฏิเสธแม้จะผ่านตัวแปรก็ตาม

// @expect-error TS2345
interface 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 first
paint(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

& บน function type สองตัวไม่ได้เฉลี่ยมันเข้าด้วยกัน — มันสร้าง value ที่เรียกได้ทั้งสอง signature นี่คือวิธีสร้าง function ที่ overload จาก function ธรรมดาสองตัว:

// @run
type 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 signatures
console.log(fn("hi")); // ✅ matches StrFn
console.log(fn(5)); // ✅ matches NumFn
// HI
// 10

union ของ function type สองตัวเดียวกันเข้มงวดกว่ามาก: เรียกได้ด้วย argument ที่ valid สำหรับทุก member เท่านั้น เพราะ compiler ไม่รู้ตรงจุดที่เรียกว่ามันเป็นตัวไหนจริง ๆ

// @expect-error TS2554
type 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 signatures
console.log(fn("hi")); // matches StrFn
console.log(fn(5)); // matches NumFn
Open in TS Playground

TypeScript 7 เรียงลำดับ member ของ union แบบ deterministic (--stableTypeOrdering เปิดถาวร) hover หรือ error message จึงอาจแสดง string | number ในจุดที่ 6.0 เคยแสดง number | string เรื่อง assignability ไม่เปลี่ยนเลย — union คือเซต — แต่ test ที่ snapshot ข้อความ error หรือ output .d.ts อาจต้อง generate ใหม่

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"]
union กับ intersection ขยับขอบเขตเซตไปคนละทาง

ถามคำถามเดียวกันทุกครั้งที่เจอ | หรือ &: กำลังอธิบายเซตของ 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 ได้โดยไม่ต้องใช้ inheritanceproperty primitive ที่ชนกันทำให้ intersection ยุบเป็น never
Intersect function typeสร้าง value ที่ overload ได้จากสอง signatureimplementation จริงตอน runtime ต้อง handle ทุก signature จริง ๆ — type แค่สัญญาไว้ ไม่ได้ให้ implementation
Union ของ interface ที่ optional ล้วนสะดวกเมื่อทุก field เป็น optional จริง ๆ ในทุก membercheck “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 นั้นกลายเป็น never type ยังมีอยู่ (แค่ field นั้นสร้างไม่ได้จริง ๆ)
  • union ปฏิเสธ object ที่มี property ไม่รู้จักเหมือน object type ตัวเดียว มันปฏิเสธแค่ literal แบบ fresh ที่ไม่มี property ร่วมกับ member ไหนเลย — ตัวแปรที่มีอยู่แล้ว หรือตัวที่มี property ร่วมแม้แค่ตัวเดียว ก็ผ่านไปได้เฉย ๆ

ก่อนเชื่อ 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 จะผิดแค่ไหน

ระหว่างถือ value type string | number คุณแตะ member ไหนได้โดยไม่ต้อง narrow?
intersection ของ object type สองตัว A & B ต้องการอะไร?
อันไหนกว้างกว่า (อนุญาต value มากกว่า): union หรือ intersection?
คุณอยากได้ "value ที่เป็นหนึ่งใน object shape หลายแบบ" ควรหยิบอะไรมาใช้?
fn(5) compile ผ่านไหม?
type StrFn = (x: string) => string;
type NumFn = (x: number) => number;
type Overloaded = StrFn & NumFn;

declare const fn: Overloaded;
fn(5);
property dta ที่พิมพ์ผิดถูกจับได้ไหม?
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" };
handle(response);