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

Literals & Widening

literal type คือ type ที่มี value ได้ค่าเดียวคือ literal ที่ระบุ "GET" เป็น type และ value เดียวที่ assign ให้ type นี้ได้คือ string "GET" เช่นเดียวกับ 42, true และอื่น ๆ

// @expect-error TS2322
let 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 ได้

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 TS2345
const 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 จุดนี้ทำทุกคนงงครั้งหนึ่งเสมอ

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" survives

as const ทำ 3 อย่างพร้อมกัน:

  1. literal ยังเป็น literal — "GET" แทน string
  2. property กลายเป็น readonly — สะท้อน immutability ที่คุณสัญญาไว้
  3. array กลายเป็น readonly tuple — [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)"]
widening เทียบ as const

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

as const ล็อก literal ไว้แต่ไม่เช็คว่า object ตรงกับ contract หรือไม่ annotation เช็คให้แต่ แทนที่ type ที่ infer ได้ด้วย annotation นั้น satisfies เช็คให้และยังคง type แคบ ๆ ที่ infer ได้ไว้:

// @expect-error TS2339
type 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 type
theme.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 TS2322
type 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 type
console.log(palette.red);
// palette.red = "nope"; // would be TS2322 -- red's real type is number[], not Channel
Open in TS Playground
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"]
type ของ value มาจากไหน แล้วไปตกที่ไหน

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 ได้หายไป
satisfiesvalidate กับ type ไว้ พร้อมเก็บ type ที่ infer ได้แบบแคบตัวแปรไม่ใช่ชนิดตามที่ประกาศ — assign ทีหลังต้องตรงกับ shape ที่ infer ได้ที่แคบกว่า ไม่ใช่ contract ที่กว้างกว่า
  • const อย่างเดียวเก็บทุก literal ให้แคบ const หยุดแค่การ reassign binding; property ของ object ที่เป็น const ยัง widen ได้ (const config = { method: "GET" } ให้ method: string) จนกว่าจะเพิ่ม as const
  • satisfies ทำให้ตัวแปรมี 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 เอง — สองอย่างนี้รวมกันได้ด้วยซ้ำ

ก่อนเชื่อ 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 ที่ mark as 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

type ของ const x = "GET" เทียบกับ let y = "GET" คืออะไร?
ทำไม method ถึงเป็น string ใน const config = { method: "GET" }?
as const ทำอะไร?
ทำไม union ของ literal อย่าง "GET" | "POST" ถึงดีกว่า string สำหรับ parameter?
โค้ดนี้ compile ผ่านไหม เพราะอะไร?
type Channel = string | number[];

let palette = {
  red: [255, 0, 0],
  note: "middle-c",
} satisfies Record<string, Channel>;

palette.red = "not an array anymore";
ทำไม send(merged.method) compile ไม่ผ่าน?
const defaultConfig = { method: "GET", retries: 3 };
function send(m: "GET" | "POST") {}
function withOverrides(overrides: Partial<typeof defaultConfig>) {
  const merged = { ...defaultConfig, ...overrides };
  send(merged.method);
}