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

Type Inference

TypeScript ที่ดีไม่ได้เต็มไปด้วย annotation compiler infer type ส่วนใหญ่ให้ และการฝืน inference ด้วย annotation ที่ซ้ำซ้อนคือสัญญาณของมือใหม่ ทักษะที่แท้จริงคือรู้ว่า infer อะไร และ ตรงไหน ที่คุณต้องเข้าไปช่วยจริง ๆ

let count = 5; // inferred: number
const label = "Ada"; // inferred: "Ada" (a literal type!)
const items = [1, 2]; // inferred: number[]

สังเกตว่า count กับ label ถูก infer ต่างกัน และความต่างนั้นคือหนึ่งในกฎที่สำคัญที่สุดของภาษา

const reassign ไม่ได้เลย compiler จึงเก็บ type ที่ แคบสุด ไว้ — คือ literal ส่วน let reassign ได้ compiler จึง widen ไปเป็น type ทั่วไป

const a = "hello"; // type: "hello" (literal — it can't change)
let b = "hello"; // type: string (widened — b might become any string)

นี่คือเหตุผลที่พฤติกรรม “โผล่มาแล้วก็หายไป” ของ literal type สำคัญอยู่ตลอด:

// @expect-error TS2345
const method = "GET"; // type: "GET"
let method2 = "GET"; // type: string
function req(m: "GET" | "POST") {}
req(method); // ✅ "GET" fits the union
req(method2); // ❌ string is too wide for "GET" | "POST"
flowchart LR
  lit["literal value<br/>#quot;GET#quot;"] -->|const: เปลี่ยนไม่ได้| keep["type: #quot;GET#quot;<br/>(แคบ)"]
  lit -->|let: อาจเปลี่ยนได้| wide["type: string<br/>(ถูก widen)"]
const เก็บ literal; let widen

เมื่อคุณอยากได้ literal type จาก let หรือจาก property ของ object ให้ใช้ const assertion (as const) — เป็นหัวข้อเต็ม ๆ ในโมดูลถัดไป

inference ไม่ได้ไหลจากล่างขึ้นบน (จาก value ไปหา type) อย่างเดียว แต่ไหลจาก บนลงล่าง จาก context รอบ ๆ ด้วย — นี่คือ contextual typing

const nums = [1, 2, 3];
nums.forEach((n) => {
// n is inferred as number — you didn't annotate it.
// The context (an array of number) told the compiler what n must be.
console.log(n.toFixed(2));
});
window.addEventListener("click", (e) => {
// e is inferred as MouseEvent from the event name — pure contextual typing.
});

นี่คือเหตุผลที่ parameter ของ callback มักไม่ต้อง annotate: function ที่รับ callback ตัวนั้นประกาศ type ของ parameter ไว้แล้ว inference จึงไหลเข้ามา

เมื่อ value อาจเป็นได้หลาย type — เช่น array ที่มี element ปนกัน — compiler จะคำนวณ best common type ที่ครอบคลุมทั้งหมด:

interface Circle { kind: "circle"; radius: number }
interface Square { kind: "square"; side: number }
declare const circle: Circle, square: Square;
const mixed = [1, "two", 3]; // inferred: (string | number)[]
const shapes = [circle, square]; // inferred: (Circle | Square)[]

ไม่ได้เลือกอันหนึ่งแล้ว error ที่เหลือ แต่สร้าง union ที่เข้าได้กับทุก element บางครั้ง best common type กว้างกว่าที่คุณอยากได้ ที่เป็นสัญญาณว่าควร annotate ให้ชัด

ปล่อยให้ inference ทำงาน แล้ว annotate อย่างจงใจใน 3 จุด:

  1. parameter ของ function — inference อ่านใจคุณเรื่อง input ไม่ได้ (แม้ contextual typing จะครอบคลุม callback ให้)
  2. return type ของ function บน public API — การ annotate return type จับความผิดพลาด ภายใน function และบอก intent (inference ทำงานได้ แต่ return type ที่ระบุชัดคือ guardrail)
  3. เมื่อ inference กว้างกว่าที่คุณอยากได้ — เช่นคุณต้องการ literal หรือ union เฉพาะ ไม่ใช่ type ที่ถูก widen

ที่เหลือทั้งหมดให้ใช้ inference annotation ที่ซ้ำซ้อนคือ noise ที่อาจ เพี้ยน ไปจากความจริงตอน refactor

กฎ “const เก็บ literal, let widen” จริง แต่ใช้ได้แค่กับ ตัว binding เอง — ไม่ใช่ทุกอย่างที่เข้าถึงผ่านมัน ตารางนี้ครอบคลุมทุก shape ที่เจอจริง:

การประกาศValueType ที่ inferทำไม
const a = "hi"string literal"hi"a เอง reassign ไม่ได้เลย
let b = "hi"string literalstringb อาจถูก reassign เป็น string อื่นทีหลัง
const obj = { x: "hi" }object literal{ x: string } — ไม่ใช่ { x: "hi" }const ป้องกันแค่ binding obj; obj.x เป็น property ที่ mutable และยัง widen อยู่
const arr = [1, 2, 3]array literalnumber[]เหตุผลเดียวกัน — element ของ array mutable ได้แม้ arr reassign ไม่ได้
const lit = "hi" as constconst assertion"hi"as const ทำให้ value (ไม่ใช่แค่ binding) เลิก widen แบบ recursive

สองแถวกลางคือส่วนที่น่าประหลาดใจจริง ๆ: const เป็นคุณสมบัติของ ตัวแปร ไม่ใช่การ deep-freeze value obj.x = "bye" ทำได้ถูกกฎหมาย (ไม่มีอะไรห้าม) compiler จึง infer obj.x เป็น string ที่กว้างกว่าอย่างถูกต้อง ทั้งที่ obj ประกาศด้วย const

const protects the binding, not nested properties

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
const obj = { x: "hi" };
type ObjX = typeof obj.x;
const check1: Equal<ObjX, string> = true; // ✅ widened to string, not "hi"
const check2: Equal<ObjX, "hi"> = false; // ✅ confirms it's NOT the literal
const arr = [1, 2, 3];
type ArrElem = typeof arr[number];
const check3: Equal<ArrElem, number> = true; // ✅ widened to number, not 1 | 2 | 3
Open in TS Playground

กด Check types — ไม่มี error เลยยืนยันว่าทั้งสาม Equal check ผ่าน: obj.x เป็น string จริง ๆ ไม่ใช่ "hi" ทั้งที่ obj ประกาศด้วย const

Return-type inference กับ annotation: refactor ที่เปลี่ยนพฤติกรรมแบบเงียบ ๆ

หัวข้อที่มีชื่อว่า “Return-type inference กับ annotation: refactor ที่เปลี่ยนพฤติกรรมแบบเงียบ ๆ”

Inference บน return type ของ public function มี failure mode จริงที่พิสูจน์ได้: การเปลี่ยนแปลงภายในเล็ก ๆ ทำให้สิ่งที่ function return กว้างขึ้นได้โดยไม่มี error โผล่ ที่ตัว function เอง — สิ่งที่พังจะโผล่ที่อื่นแทนสิ้นเชิง

function parseAmount(input: string) {
return Number(input);
}
const total = parseAmount("42").toFixed(2); // ✅ inferred return type: number

โค้ดนี้ compile ผ่านสะอาด — parseAmount ถูก infer ให้ return number และ .toFixed เป็น method ของ number จริง ตอนนี้เพื่อนร่วมทีมเพิ่ม guard สำหรับ input ที่ผิด ซึ่งเป็นการเปลี่ยนแปลงที่สมเหตุสมผลมาก:

// @expect-error TS2532
function parseAmount(input: string) {
const n = Number(input);
if (Number.isNaN(n)) return;
return n;
}
const total = parseAmount("42").toFixed(2);
error TS2532: Object is possibly 'undefined'.

parseAmount เองยัง compile ผ่านโดยไม่บ่นเลย — TypeScript infer return type ใหม่ที่ซื่อสัตย์ของมัน number | undefined แล้วเดินหน้าต่อ error โผล่ ที่จุดเรียก อาจอยู่คนละไฟล์เลย อาจเขียนโดยคนที่ไม่เคยดู body ของ parseAmount เลยด้วยซ้ำ ถ้า annotate return type แทน การแก้เดียวกันเป๊ะจะถูกจับได้ทันที ตรงจุดที่ผิดจริง:

// @expect-error TS2322
function parseAmount(input: string): number {
const n = Number(input);
if (Number.isNaN(n)) return;
return n;
}

Type 'undefined' is not assignable to type 'number' ยิงที่บรรทัด return; เอง — ตอนที่การเปลี่ยนแปลงเกิดขึ้น ใน function ที่ถูกเปลี่ยน แทนที่จะเป็นที่ code ปลายทางที่มีเหตุผลทุกอย่างที่จะเชื่อว่าไม่มีอะไรเปลี่ยน นี่คือเวอร์ชันเป็นรูปธรรมของ “annotate public return type”: annotation ไม่ได้ทำให้ function ถูกต้องขึ้น มันแค่ย้าย ที่ ที่การเปลี่ยนแปลงที่ผิดจะถูกรายงาน — จากผู้เรียกคนไหนก็ได้ อาจช้ากว่ามาก กลับมาที่บรรทัดที่ทำให้ contract พังจริง ๆ

การชนกันของ label/name ที่ต้นบทเป็นอาการของกฎทั่วไปจริงที่ควรพูดให้ชัด: ไฟล์ที่ไม่มี import หรือ export ระดับบนสุดเลยคือ script และ script แชร์ scope กับ global ของ DOM lib name, top, length, event, close ล้วนเป็น property จริงของ Window — ประกาศตัวไหนก็ได้ที่ script scope แล้วชนทันที:

// @expect-error TS2339 TS2451
const name = "Ada";
console.log(name.toUpperCase());

Cannot redeclare block-scoped variable 'name' คือ error แรก เพราะการประกาศซ้ำพัง name จึง resolve ไปที่ ambient binding Window.name แทน (typed เป็น type ที่ใช้ตรงนี้ไม่ได้จริง ๆ) ซึ่งเป็นเหตุผลที่ error ที่สอง (TS2339, ไม่มี toUpperCase บน type ที่ได้) ตามมาติด ๆ การแก้คือสิ่งที่ไฟล์ .ts จริงทุกไฟล์มีอยู่แล้ว ปกติไม่มีใครคิดถึงมันด้วยซ้ำ:

export {};
const name = "Ada";
console.log(name.toUpperCase()); // ✅ this file is now a module — its own scope, no collision

export {} เปล่า ๆ ไม่ export อะไรเลย แต่พอที่จะทำให้ไฟล์เป็น module แทนที่จะเป็น script — module มี scope ของตัวเองและไม่เห็น ambient global เลยนอกจากจะอ้างถึง window.name ตรง ๆ ไฟล์ที่มี import หรือ export จริงได้สิ่งนี้ฟรีอยู่แล้ว export {} คือการแก้บรรทัดเดียวสำหรับไฟล์ที่ยังไม่มีทั้งสองอย่าง

callback inference ใน object literal (TypeScript 6.0) และการบล็อก inference site ด้วย NoInfer<T> (5.4) เป็นเรื่องของ inference ก็จริง แต่ทั้งคู่ต้องใช้ generics (<T>) ถึงจะเข้าใจ จึงย้ายไปอยู่ท้ายบท generics/inference-and-infer — กลับมาอ่านหลังจบโมดูล Generics

Inference กำลังทำงานอย่างเดียวเป๊ะ ๆ ทุกที่ในบทนี้: คำนวณ type ที่กว้างที่สุดที่ยังสอดคล้องกับทุกอย่างที่ compiler เห็นจริง let เห็น “อาจถูก reassign” แล้ว widen; property บน object ที่เป็น const เห็น “อาจถูก mutate” แล้ว widen ด้วยเหตุผลเดียวกัน ไม่ว่า binding เองจะเป็น const แค่ไหน; function ที่ไม่มี return annotation เห็น “ทุก value ที่มันอาจ return ได้ ณ วันนี้” แล้ว infer เป็น union — ซึ่งโตขึ้นทันทีที่มีการเพิ่ม code path ใหม่ โดยไม่มีอะไรมา flag การโตนั้นเลย นอกจากที่ที่ผลลัพธ์ที่กว้างขึ้นถูกใช้ Annotation คือคุณ override การคำนวณนั้นด้วยคำสัญญาที่ compiler จะเช็คกับมันนับจากนี้ แทนที่จะอนุมานมันเอง

flowchart TB
  seen["What the compiler has
actually seen so far"] --> widest["Widest type
consistent with that"]
  widest -->|"no annotation"| silent["Grows silently when
the code changes"]
  widest -->|"explicit annotation"| checked["Compiler checks NEW code
against the OLD promise"]
Inference คำนวณ type ที่กว้างที่สุดที่สอดคล้องกับสิ่งที่เห็น
ตัวเลือกข้อดีต้นทุน
Return type แบบ infer (ไม่ annotate)เขียนน้อยกว่า; return type อัปเดตอัตโนมัติเมื่อ function เปลี่ยนการเปลี่ยนที่ทำให้กว้างขึ้น (return; ใหม่ที่ไม่มี value) รายงาน error ที่ทุกจุดเรียกแทนที่จะเป็นที่ function
Return type แบบ annotateการแก้ที่เปลี่ยนพฤติกรรมถูกจับได้ที่ตัว function เองทันทีต้องอัปเดต annotation เองตอนตั้งใจเปลี่ยน — เพิ่มบรรทัดที่ต้องดูแล
const obj = {...} พึ่ง property type ที่ inferพิธีกรรมน้อยกว่าสำหรับ object ทั่วไปProperty ยัง widen อยู่ — ถ้าต้องการ literal property type const อย่างเดียวให้ไม่ได้
as constล็อก literal แบบ recursive ทั้งก้อน ไม่ใช่แค่ binding ระดับบนสุดทำให้ value (และทุกอย่างข้างในมัน) เป็น readonly ซึ่งไม่ใช่สิ่งที่ต้องการเสมอ
ไฟล์ script scope (ไม่มี import/export)ไม่มีข้อดีจริง ๆ สำหรับ application codeแชร์ global scope กับ ambient ของ DOM lib (name, top, length, …) — กับดักการชนแบบเงียบ ๆ
  • const แปลว่า value ทั้งก้อนถูก freeze มันแปลว่าแค่ binding reassign ไม่ได้ const obj = { x: 1 }; obj.x = 2; ถูกกฎหมายสมบูรณ์ และ type ที่ compiler infer ให้ obj.x สะท้อนสิ่งนั้นด้วยการ widen ไม่ใช่การแคบลงเป็น literal
  • Return type แบบ infer ปลอดภัยพอ ๆ กับแบบ annotate พฤติกรรมเหมือนกันจนกว่า body ของ function จะเปลี่ยน พอเปลี่ยนแล้ว return type แบบ infer เปลี่ยนสิ่งที่สัญญากับผู้เรียกแบบเงียบ ๆ ส่วนแบบ annotate เปลี่ยนการเปลี่ยนแปลงเดียวกันให้เป็น error ที่ตัว function เอง
  • ไฟล์ .ts ทุกไฟล์เสี่ยงชนกับ name/top/length เฉพาะไฟล์ที่ไม่มี import/export เลย (script จริง) เท่านั้น ทันทีที่ไฟล์มี import หรือ export จริง มันเป็น module ที่มี scope ของตัวเอง — export {} คือการแก้แบบเดียวกัน สะกดออกมาให้ไฟล์ที่ยังไม่มีทั้งสองอย่าง

Code ที่ generate มามักละ return-type annotation บน function ที่ export ออกไป ซึ่งซ่อน failure mode ที่บทนี้เพิ่งพิสูจน์พอดี ตอน review diff ที่เพิ่ม early return ใหม่, ทางเดิน error ใหม่ หรือ ?? undefined fallback ข้างใน function ที่มีอยู่แล้วให้เช็ค:

  • function มี return type ที่ชัดเจนไหม? ถ้าไม่มี คนเขียน diff (หรือคน review) อนุมาน type ที่ function return ตอนนี้ใหม่จริงไหม หรือแค่เชื่อว่า “ยัง compile ผ่าน” แปลว่า “ไม่มีอะไรเปลี่ยน”?
  • ค้นหาทุกจุดเรียกของ function ที่เปลี่ยน มีจุดไหนเรียก method บนผลลัพธ์โดยไม่เช็ค null/undefined เลยไหม — ตรงกับ shape ที่พังพอดีเมื่อ return type โตขึ้นเป็น | undefined แบบเงียบ ๆ?
  • Property ของ object ที่ประกาศด้วย const ถูกปฏิบัติเหมือนเป็น literal type ที่ไหนสักแห่งด้านล่างไหม ทั้งที่ไม่มีอะไรล็อกมันด้วย as const จริง ๆ?

🐛 หา bug ในโค้ดนี้

function getDiscount(code: string) {
const discounts: Record<string, number> = { SAVE10: 0.1 };
if (!(code in discounts)) return;
return discounts[code];
}
function applyDiscount(price: number, code: string): number {
const discount = getDiscount(code)!;
return price * (1 - discount);
}
console.log(applyDiscount(100, "INVALID"));
เฉลย

โค้ดนี้ compile ผ่านโดยไม่มี error เลยแล้วได้ NaN แทนที่จะ throw error หรือมี fallback ที่สมเหตุสมผล getDiscount ไม่มี return-type annotation ดังนั้น type ที่ infer ให้มันกลายเป็น number | undefined แบบเงียบ ๆ ทันทีที่ guard if (!(code in discounts)) return; ถูกเพิ่มเข้ามา compiler เคย flag การคำนวณเลขบน value ที่อาจ undefined ข้างใน applyDiscount ถูกต้องแล้ว — แต่ ! ที่ต่อท้าย getDiscount(code) ระงับ warning นั้นพอดี (นี่คือบทเรื่อง assertion จาก Why TypeScript ที่โผล่มาอีกครั้งตรงนี้) จึงไม่เหลืออะไรมาจับมันได้ discount เป็น undefined จริง ๆ สำหรับ "INVALID" 1 - undefined คือ NaN และ price * NaN คือ NaN โดยไม่มี exception ที่ไหนเลย ถ้า annotate getDiscount(code: string): number เรื่องนี้จะโผล่เป็น compile error ที่บรรทัด return; ของ getDiscount เองทันทีที่ guard ถูกเพิ่มเข้ามา — กลไกเดียวกับตัวอย่าง parseAmount ก่อนหน้าในบทนี้ — แทนที่จะหวังว่าจะไม่มีใครใช้ ! มาทำให้ warning ที่ถูกต้องข้างล่างหายไป

const x = "GET" ถูก infer เป็น type อะไร?
ทำไม let y = "GET" ถึง infer เป็น string ขณะที่ const x = "GET" infer เป็น "GET"?
ทำไม parameter ของ callback มักไม่ต้อง annotate type?
TypeScript infer อะไรให้ [1, "two", 3]?
ObjX คืออะไร และทำไม?
const obj = { x: "hi" };
type ObjX = typeof obj.x;
Compile error ที่เกิดขึ้นโผล่ที่ไหน และทำไมถึงสำคัญ?
function parseAmount(input: string) {
  const n = Number(input);
  if (Number.isNaN(n)) return;
  return n;
}
const total = parseAmount("42").toFixed(2);
ทำไม const name = "Ada"; ที่ระดับบนสุดของไฟล์ .ts แบบ script scope (ไม่มี import/export) ถึง compile ไม่ผ่าน?