Type Inference
คุณเขียน type น้อยกว่าที่คิด
หัวข้อที่มีชื่อว่า “คุณเขียน type น้อยกว่าที่คิด”TypeScript ที่ดีไม่ได้เต็มไปด้วย annotation compiler infer type ส่วนใหญ่ให้ และการฝืน inference ด้วย annotation ที่ซ้ำซ้อนคือสัญญาณของมือใหม่ ทักษะที่แท้จริงคือรู้ว่า infer อะไร และ ตรงไหน ที่คุณต้องเข้าไปช่วยจริง ๆ
let count = 5; // inferred: numberconst label = "Ada"; // inferred: "Ada" (a literal type!)const items = [1, 2]; // inferred: number[]สังเกตว่า count กับ label ถูก infer ต่างกัน และความต่างนั้นคือหนึ่งในกฎที่สำคัญที่สุดของภาษา
let widen, const เก็บ literal
หัวข้อที่มีชื่อว่า “let widen, const เก็บ literal”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 TS2345const method = "GET"; // type: "GET"let method2 = "GET"; // type: string
function req(m: "GET" | "POST") {}req(method); // ✅ "GET" fits the unionreq(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)"]
เมื่อคุณอยากได้ literal type จาก let หรือจาก property ของ object ให้ใช้ const assertion (as const) — เป็นหัวข้อเต็ม ๆ ในโมดูลถัดไป
Contextual typing: inference ไหลเข้าด้านใน
หัวข้อที่มีชื่อว่า “Contextual typing: inference ไหลเข้าด้านใน”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 จึงไหลเข้ามา
Best common type
หัวข้อที่มีชื่อว่า “Best common type”เมื่อ 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 ให้ชัด
เมื่อไรควร annotate
หัวข้อที่มีชื่อว่า “เมื่อไรควร annotate”ปล่อยให้ inference ทำงาน แล้ว annotate อย่างจงใจใน 3 จุด:
- parameter ของ function — inference อ่านใจคุณเรื่อง input ไม่ได้ (แม้ contextual typing จะครอบคลุม callback ให้)
- return type ของ function บน public API — การ annotate return type จับความผิดพลาด ภายใน function และบอก intent (inference ทำงานได้ แต่ return type ที่ระบุชัดคือ guardrail)
- เมื่อ inference กว้างกว่าที่คุณอยากได้ — เช่นคุณต้องการ literal หรือ union เฉพาะ ไม่ใช่ type ที่ถูก widen
ที่เหลือทั้งหมดให้ใช้ inference annotation ที่ซ้ำซ้อนคือ noise ที่อาจ เพี้ยน ไปจากความจริงตอน refactor
let กับ const widening แบบเต็ม ๆ
หัวข้อที่มีชื่อว่า “let กับ const widening แบบเต็ม ๆ”กฎ “const เก็บ literal, let widen” จริง แต่ใช้ได้แค่กับ ตัว binding เอง — ไม่ใช่ทุกอย่างที่เข้าถึงผ่านมัน ตารางนี้ครอบคลุมทุก shape ที่เจอจริง:
| การประกาศ | Value | Type ที่ infer | ทำไม |
|---|---|---|---|
const a = "hi" | string literal | "hi" | a เอง reassign ไม่ได้เลย |
let b = "hi" | string literal | string | b อาจถูก 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 literal | number[] | เหตุผลเดียวกัน — element ของ array mutable ได้แม้ arr reassign ไม่ได้ |
const lit = "hi" as const | const 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 กด 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 TS2532function 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 TS2322function 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 พังจริง ๆ
Module กับ script scope แบบเต็ม ๆ
หัวข้อที่มีชื่อว่า “Module กับ script scope แบบเต็ม ๆ”การชนกันของ label/name ที่ต้นบทเป็นอาการของกฎทั่วไปจริงที่ควรพูดให้ชัด: ไฟล์ที่ไม่มี import หรือ export ระดับบนสุดเลยคือ script และ script แชร์ scope กับ global ของ DOM lib name, top, length, event, close ล้วนเป็น property จริงของ Window — ประกาศตัวไหนก็ได้ที่ script scope แล้วชนทันที:
// @expect-error TS2339 TS2451const 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 collisionexport {} เปล่า ๆ ไม่ export อะไรเลย แต่พอที่จะทำให้ไฟล์เป็น module แทนที่จะเป็น script — module มี scope ของตัวเองและไม่เห็น ambient global เลยนอกจากจะอ้างถึง window.name ตรง ๆ ไฟล์ที่มี import หรือ export จริงได้สิ่งนี้ฟรีอยู่แล้ว export {} คือการแก้บรรทัดเดียวสำหรับไฟล์ที่ยังไม่มีทั้งสองอย่าง
สองหัวข้อ inference ที่ต้องรู้ generics ก่อน
หัวข้อที่มีชื่อว่า “สองหัวข้อ inference ที่ต้องรู้ generics ก่อน”callback inference ใน object literal (TypeScript 6.0) และการบล็อก inference site ด้วย NoInfer<T> (5.4) เป็นเรื่องของ inference ก็จริง แต่ทั้งคู่ต้องใช้ generics (<T>) ถึงจะเข้าใจ จึงย้ายไปอยู่ท้ายบท generics/inference-and-infer — กลับมาอ่านหลังจบโมดูล Generics
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”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"]
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
| 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 {}คือการแก้แบบเดียวกัน สะกดออกมาให้ไฟล์ที่ยังไม่มีทั้งสองอย่าง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”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 ที่ถูกต้องข้างล่างหายไป