Literals & Widening
literal คือเซตที่มีสมาชิกตัวเดียว
หัวข้อที่มีชื่อว่า “literal คือเซตที่มีสมาชิกตัวเดียว”literal type คือ type ที่มี value ได้ค่าเดียวคือ literal ที่ระบุ "GET" เป็น type และ value เดียวที่ assign ให้ type นี้ได้คือ string "GET" เช่นเดียวกับ 42, true และอื่น ๆ
// @expect-error TS2322let method: "GET" = "GET";method = "POST"; // ❌ Type '"POST"' is not assignable to type '"GET"'.ลำพัง literal type แทบไม่มีประโยชน์ พลังของตัวเองมาจาก union ของ literal ซึ่ง model ความหมาย “หนึ่งในค่าที่ระบุพวกนี้”:
type Method = "GET" | "POST" | "PUT" | "DELETE";type Dice = 1 | 2 | 3 | 4 | 5 | 6;นี่คือวิธีแทน parameter แบบ string หลวม ๆ ด้วยตัวที่แม่นยำ อธิบายตัวเองได้ และ autocomplete ได้
Widening: ทำไม literal ถึงหายไปเรื่อย ๆ
หัวข้อที่มีชื่อว่า “Widening: ทำไม literal ถึงหายไปเรื่อย ๆ”TypeScript จะ widen literal ไปเป็น type ทั่วไปในที่ที่ value อาจเปลี่ยนได้ กฎที่คุณเจอใน Foundations: const เก็บ literal, let widen
const a = "GET"; // type: "GET"let b = "GET"; // type: string (widened — b is reassignable)widening แบบเดียวกันเกิดกับ property ของ object ด้วย เพราะ property ของ object แก้ค่าได้โดย default:
// @expect-error TS2345const config = { method: "GET" };// type: { method: string } — NOT { method: "GET" }
function send(m: "GET" | "POST") {}send(config.method); // ❌ string is not assignable to "GET" | "POST"ถึง config จะเป็น const แต่ property method ยัง reassign ได้ (config.method = "POST" ทำได้) compiler เลย widen เป็น string จุดนี้ทำทุกคนงงครั้งหนึ่งเสมอ
as const: ล็อก type แบบแคบไว้
หัวข้อที่มีชื่อว่า “as const: ล็อก type แบบแคบไว้”const assertion (as const) บอก compiler ว่า “มองค่านี้เป็น immutable แบบลึก และเก็บทุก literal ให้แคบไว้”
function send(m: "GET" | "POST") {}
const config = { method: "GET" } as const;// type: { readonly method: "GET" }
send(config.method); // ✅ now "GET" survivesas const ทำ 3 อย่างพร้อมกัน:
- literal ยังเป็น literal —
"GET"แทนstring - property กลายเป็น
readonly— สะท้อน immutability ที่คุณสัญญาไว้ - array กลายเป็น
readonlytuple —[1, 2]กลายเป็นreadonly [1, 2]ไม่ใช่number[]
flowchart TB
val["value: { method: 'GET' }"] -->|default| wide["type: { method: string }<br/>(widened, mutable)"]
val -->|as const| narrow["type: { readonly method: 'GET' }<br/>(literal, readonly)"] use case ที่พบบ่อยมาก: literal config object
หัวข้อที่มีชื่อว่า “use case ที่พบบ่อยมาก: literal config object”as const เปล่งประกายกับ lookup table และ config ที่คุณอยากได้ key/value เป๊ะ ๆ ใน type:
const ROUTES = { home: "/", profile: "/profile", settings: "/settings",} as const;
type Route = typeof ROUTES[keyof typeof ROUTES];// type: "/" | "/profile" | "/settings"ถ้าไม่มี as const ROUTES จะเป็น { home: string; profile: string; settings: string } แล้วคุณก็เสีย path เฉพาะทั้งหมด แต่พอมี คุณ derive union type ที่แม่นยำจากตัวข้อมูลได้ตรง ๆ — pattern ที่คุณจะเจอตลอดในโมดูล type-level
satisfies: เช็คโดยไม่ widen (4.9)
หัวข้อที่มีชื่อว่า “satisfies: เช็คโดยไม่ widen (4.9)”as const ล็อก literal ไว้แต่ไม่เช็คว่า object ตรงกับ contract หรือไม่ annotation เช็คให้แต่ แทนที่ type ที่ infer ได้ด้วย annotation นั้น satisfies เช็คให้และยังคง type แคบ ๆ ที่ infer ได้ไว้:
// @expect-error TS2339type Theme = Record<string, string | [number, number, number]>;
const theme = { primary: "#0af", accent: [255, 128, 0],} satisfies Theme;
theme.accent[0]; // ✅ number — still a tuple, not widened to the Theme value typetheme.typo; // ❌ property does not exist — the object is fully knownใช้คู่กับ as const ({ … } as const satisfies Theme) เมื่ออยากได้ทั้ง member แบบ readonly literal และ การ validate ส่วนการรักษา literal ผ่าน generic function call ดู const type parameter ในโมดูล Generics
กับดักของ satisfies: มันเก็บ type ที่ infer ได้ ไม่ใช่ type ที่ประกาศ
หัวข้อที่มีชื่อว่า “กับดักของ satisfies: มันเก็บ type ที่ infer ได้ ไม่ใช่ type ที่ประกาศ”satisfies มักถูกอธิบายว่าเป็น “annotation ที่เช็คด้วย” — แต่มันไม่ได้กลายเป็น annotation นั้นจริง ๆ type ของตัวแปรยังเป็นสิ่งที่ TypeScript infer ได้จาก literal ซึ่งมักจะแคบกว่า type ที่คุณเอาไป validate ความแคบนี้มีสองด้าน:
type Channel = string | number[];
let palette = { red: [255, 0, 0], note: "middle-c",} satisfies Record<string, Channel>;
palette.red.push(0); // ✅ good -- inferred as number[], not widened to string | number[]// @expect-error TS2322type Channel = string | number[];
let palette = { red: [255, 0, 0], note: "middle-c",} satisfies Record<string, Channel>;
palette.red = "not an array anymore"; // reassigning within the DECLARED union, Channel// ❌ Type 'string' is not assignable to type 'number[]'.type จริงของ palette.red คือ number[] — literal type ที่ infer ได้ — ไม่ใช่ Channel การ reassign เป็น string ถูกต้องตามหลัก type ที่ประกาศไว้ Record<string, Channel> แต่ satisfies ไม่เคย widen ตัวแปรให้เป็น type ที่ประกาศนั้น การ assign เลยล้มเหลวเทียบกับ type ที่ infer ได้ซึ่งแคบกว่า เทียบกับ annotation ธรรมดา ที่ใช้ type ที่ประกาศเป็น type จริงของตัวแปร และไม่มีข้อจำกัดแบบนี้:
type Channel = string | number[];
let palette: Record<string, Channel> = { red: [255, 0, 0], note: "middle-c",};
palette.red = "not an array anymore"; // ✅ fine -- the declared type is the wide unionหยิบ satisfies มาใช้เมื่อคุณอยากให้ type ที่ infer ได้แบบแคบไหลต่อไป (กรณีที่พบบ่อยและต้องการ) หยิบ annotation ธรรมดามาใช้แทนเมื่อตัวแปรเองต้องรับได้ทุก member ของ type ที่ประกาศตลอดอายุของมัน — satisfies เช็คแค่ครั้งเดียวตอน literal แล้วก็ถอยออกไป
satisfies keeps the inferred type
type Channel = string | number[];
let palette = { red: [255, 0, 0], note: "middle-c",} satisfies Record<string, Channel>;
palette.red.push(0); // ✅ number[] -- the narrow inferred typeconsole.log(palette.red);
// palette.red = "nope"; // would be TS2322 -- red's real type is number[], not Channel Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart LR
lit["Literal value written
('GET', 42, true)"] --> ctx{"Could this binding change?"}
ctx -->|"no -- const, readonly prop"| narrow["Stays the literal type"]
ctx -->|"yes -- let, mutable prop"| wide["Widens to the base type
(string, number, boolean)"]
narrow --> asconst["as const locks the whole value tree
(nested props + arrays too)"]
wide -->|"satisfies"| checked["Checked against a type,
keeps its own inferred type"] literal ทุกตัวเริ่มต้นเป็น value เป๊ะ ๆ ที่คุณเขียน จากนั้น TypeScript ถามคำถามเดียว — “binding นี้ reassign ได้ไหม” — แล้ว widen ก็ต่อเมื่อคำตอบคือได้ as const คือคำตอบ “ไม่ได้” แบบครอบคลุมที่ใช้กับทั้ง value tree (property ของ object กลายเป็น readonly, array กลายเป็น tuple) ส่วน satisfies ไม่แตะ widening เลย มันแค่เช็ค type ที่ infer ได้แล้วกับ contract แล้วส่ง type ที่ infer ได้ตัวเดิมกลับมา
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| แนวทาง | ได้อะไร | เสียอะไร |
|---|---|---|
literal ธรรมดา (let x = "GET") | เขียนง่ายที่สุด | widen เป็น string — เสีย value เป๊ะ ๆ ทันทีที่ reassign ได้ |
as const | ล็อก literal, property ที่ซ้อนอยู่ข้างใน และ array element ทั้งหมดพร้อมกัน | ทุกอย่างกลายเป็น readonly — mutation ต้องทำสำเนาใหม่ที่ไม่ใช่ const |
annotation (: Theme) | ตัวแปรมี type ตรงตามที่ประกาศเป๊ะ | property ส่วนเกินถูกซ่อน และความแม่นยำของ literal ที่ infer ได้หายไป |
satisfies | validate กับ type ไว้ พร้อมเก็บ type ที่ infer ได้แบบแคบ | ตัวแปรไม่ใช่ชนิดตามที่ประกาศ — assign ทีหลังต้องตรงกับ shape ที่ infer ได้ที่แคบกว่า ไม่ใช่ contract ที่กว้างกว่า |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”constอย่างเดียวเก็บทุก literal ให้แคบconstหยุดแค่การ reassign binding; property ของ object ที่เป็นconstยัง widen ได้ (const config = { method: "GET" }ให้method: string) จนกว่าจะเพิ่มas constsatisfiesทำให้ตัวแปรมี type ตามที่ satisfy ไว้ ไม่จริง — ตัวแปรเก็บ type ที่ infer ได้ของตัวเอง ซึ่งมักจะแคบกว่า assign ค่าที่ valid สำหรับ type ที่ประกาศแต่ไม่ valid สำหรับ type ที่ infer ได้ ก็ยังล้มเหลวอยู่ดีas constกับ type annotation ทำงานเหมือนกัน annotation แทนที่ type ด้วยสิ่งที่คุณเขียน (ซ่อน property ส่วนเกิน widen literal);as constเก็บ type ที่ literal, แคบ,readonlyที่ infer ได้จาก value เอง — สองอย่างนี้รวมกันได้ด้วยซ้ำ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ literal หรือ config object ที่ generate มา ให้เช็คสิ่งเหล่านี้:
- config object ที่ส่งเข้า function ที่ต้องการ literal union โดยไม่มี
as const— property ของ object literal ธรรมดา widen เป็นstring/numberแล้ว call จะ fail ก็ต่อเมื่อ union check รันจริง ซึ่งมักจะห่างจาก object ตัวจริงไปหลาย function satisfiesที่ถูกใช้ในจุดที่คนเขียนตั้งใจจะใช้ annotation จริง ๆ (หรือกลับกัน) — ดูว่าเกิดอะไรขึ้นตอน assignครั้งถัดไปให้ตัวแปร ไม่ใช่แค่ครั้งแรก เพราะช่องว่างระหว่าง inferred กับ declared จะโผล่ตรงนั้นas constที่ใช้กับสิ่งที่กำลังจะถูก mutate — spread หรือ.pushบน value ที่ markas constไว้คือ error ละเมิดreadonlyที่รอเกิดขึ้น ไม่ใช่ runtime bug ที่ type system พลาดไป
🐛 หา bug ในโค้ดนี้
const defaultConfig = { method: "GET", retries: 3 };
function send(m: "GET" | "POST") {}
function withOverrides(overrides: Partial<typeof defaultConfig>) {const merged = { ...defaultConfig, ...overrides };send(merged.method);}เฉลย
defaultConfig ไม่มี as const เลย typeof defaultConfig infer เป็น { method: string; retries: number } — literal "GET" widen เป็น string ไปแล้วตั้งแต่จุดที่ประกาศ spread เข้า merged ก็ไม่ทำให้ literal กลับมา merged.method เลยเป็น string diagnostic จริงคือ TS2345: Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"' ทางแก้คือ const defaultConfig = { method: "GET", retries: 3 } as const; (หรือ annotate method ด้วย literal union ตรง ๆ) เพื่อให้ literal รอดผ่าน spread