Inference & infer
inference เอาคำตอบมาจากไหน: inference site
หัวข้อที่มีชื่อว่า “inference เอาคำตอบมาจากไหน: inference site”เมื่อคุณเรียก generic function โดยไม่ส่ง type argument TypeScript จะดูทุกที่ที่ type parameter ปรากฏใน parameter — แต่ละที่คือ inference site — แล้วคำนวณ type ที่เข้ากับทุกที่ได้
function pair<A, B>(a: A, b: B): [A, B] { return [a, b];}
const p = pair(1, "hi");// ^? [number, string] — A from the first arg, B from the secondเมื่อ parameter หนึ่งปรากฏมากกว่าหนึ่งครั้ง TypeScript จะประนีประนอมระหว่าง candidate — แต่ “ประนีประนอม” ไม่ได้แปลว่า “รวมเป็น best common type” argument แต่ละตัวถูกเช็คกับ T ตัวเดียวกัน ทีละ candidate:
function both<T>(a: T, b: T): T { return a; }
const x = both(1, 2);// ^? 1 | 2 — two literal candidates for the same T, kept as a union// @expect-error TS2345function both<T>(a: T, b: T): T { return a; }
const y = both(1, "two"); // the first argument fixes T = 1, so "two" doesn't fitargument แรกกลายเป็น candidate ที่ใช้งานของ T; ตัวที่สองถูกเช็ค กับ candidate นั้น ไม่ได้ถูก merge เป็น common type ที่กว้างขึ้น ดังนั้น 1 กับ "two" ไม่ได้ประนีประนอมเป็น number | string แต่ขัดกัน ถ้าอยากได้ union จาก both จริง ๆ ต้องระบุชัด:
function both<T>(a: T, b: T): T { return a; }
const z = both<number | string>(1, "two");// ^? number | stringการเข้าใจว่า inference อ่าน ตำแหน่งของ argument อธิบายโมเมนต์ “ทำไม compiler infer แบบนั้น” ได้เกือบทั้งหมด — และอธิบายว่าทำไมการ annotate return type ไม่เคยเปลี่ยนสิ่งที่ argument infer ออกมา
infer: ดึง type ออกมาจากข้างใน type อื่น
หัวข้อที่มีชื่อว่า “infer: ดึง type ออกมาจากข้างใน type อื่น”ภายใน conditional type keyword infer แนะนำ type variable ใหม่ที่ TypeScript แก้ให้คุณ ด้วยการ pattern-match structure นี่คือวิธีดึงชิ้นส่วนออกจาก type ที่ใหญ่กว่า
ตัวอย่างคลาสสิก — เอา return type ของ function:
interface User { id: string; name: string }
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type A = MyReturnType<() => number>; // numbertype B = MyReturnType<(x: string) => User>; // Usertype C = MyReturnType<string>; // never (not a function)อ่าน infer R ว่า “match type นี้กับ pattern (...args) => R ถ้าเข้ากันได้ ให้ bind R เป็นอะไรก็ตามที่อยู่ในตำแหน่ง return” นี่คือวิธีที่ built-in ReturnType<T> ถูกนิยามโดยประมาณ — ตัวจริง (lib.es5.d.ts) เพิ่ม constraint กับ fallback ที่ต่างออกไป: type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any; constraint รับประกันว่า T เรียกได้จริง และ false branch เป็น any ไม่ใช่ never — ดังนั้นการส่ง type ที่ไม่ใช่ function เข้าไปจะไม่ทำให้สิ่งที่ใช้ผลลัพธ์ต่อ “เป็นพิษ” เหมือนที่ never จะทำ (never จะหายไปเงียบ ๆ จาก union ส่วน any ยังอยู่ให้เห็นและ complain ทันทีตอนใช้ครั้งแรก)
pattern นี้ใช้ได้ทั่วไป: ดึงส่วนไหนออกมาก็ได้
หัวข้อที่มีชื่อว่า “pattern นี้ใช้ได้ทั่วไป: ดึงส่วนไหนออกมาก็ได้”infer จับตำแหน่งไหนใน structure ก็ได้ — element type, promise value, argument type:
// element type of an arraytype ElementType<T> = T extends (infer E)[] ? E : T;type E1 = ElementType<number[]>; // numbertype E2 = ElementType<string>; // string (unchanged — not an array)
// the value a Promise resolves totype Awaited1<T> = T extends Promise<infer V> ? V : T;type V1 = Awaited1<Promise<string>>; // string
// the first parameter typetype FirstArg<T> = T extends (first: infer P, ...rest: any[]) => any ? P : never;type P1 = FirstArg<(id: number) => void>; // numberทุกอันคือการทำแบบเดียวกัน: บรรยาย shape ที่คุณคาดหวัง วาง infer X ตรงที่ส่วนที่น่าสนใจอยู่ แล้ว TypeScript เติม X ให้เมื่อ shape เข้ากัน
จุดที่ต้องระวัง: infer หลายตัวในตำแหน่งเดียวกัน
หัวข้อที่มีชื่อว่า “จุดที่ต้องระวัง: infer หลายตัวในตำแหน่งเดียวกัน”เมื่อชื่อ infer เดียวกันปรากฏหลายจุด พฤติกรรมของ TypeScript ขึ้นกับตำแหน่ง — โดย infer เป็น union ในตำแหน่ง covariant และเป็น intersection ในตำแหน่ง contravariant (parameter) คุณไม่ค่อยต้องใช้ แต่อธิบายเซอร์ไพรส์บางอย่าง:
type Merge<T> = T extends { a: infer U; b: infer U } ? U : never;type M = Merge<{ a: string; b: number }>; // string | number (union)บท Conditional Types เอากรณีนี้มาวางเทียบข้าง ๆ กับกรณี contravariant (intersection) แล้วพิสูจน์ทั้งคู่ด้วย type equality check
NoInfer<T>: block inference site (5.4)
หัวข้อที่มีชื่อว่า “NoInfer<T>: block inference site (5.4)”ทุกที่ที่ type parameter ปรากฏใน parameter list คือ inference site — รวมถึงจุดที่คุณไม่อยากให้มัน มีส่วนร่วม parameter แบบ “default” หรือ “fallback” คือตัวอย่างคลาสสิก:
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {}
createStreetLight(["red", "yellow", "green"], "blue");// ✅ compiles — C is inferred as "red" | "yellow" | "green" | "blue", widened by defaultColorนั่นมักไม่ใช่สิ่งที่คุณต้องการ: "blue" ไม่ใช่สีจริงตัวหนึ่ง แต่เพราะ defaultColor ก็เป็น inference site ของ C ด้วย มันเลยแค่ทำให้ union กว้างขึ้นจนรวม "blue" เข้าไป ห่อจุดที่ไม่อยากให้มีส่วนร่วมด้วย built-in NoInfer<T>:
// @expect-error TS2345function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) {}
createStreetLight(["red", "yellow", "green"], "blue");NoInfer erase กลายเป็น T ในระดับ type — มันเปลี่ยน ที่ที่ compiler มองหา candidate ไม่ใช่ type ที่ได้ เมื่อใส่แล้ว defaultColor จะถูกเช็คกับ C ที่ infer จาก colors เพียงอย่างเดียว ทำให้ "blue" fail ถูกต้อง (บท Type Inference ของโมดูล Foundations แนะนำเรื่องนี้แบบสั้น ๆ ไว้ ส่วนนี้คือภาพเต็ม ตอนนี้ generics และ inference site อยู่ใน scope แล้ว)
callback inference ใน object literal (6.0)
หัวข้อที่มีชื่อว่า “callback inference ใน object literal (6.0)”contextual typing มีจุดอ่อนคลาสสิก: เมื่อ object literal มี callback หลายตัวและ T ต้อง infer จากตัวใดตัวหนึ่ง TypeScript เคยนับ ทุกตัว เป็น context-sensitive defer รวมกันแล้วยอมแพ้ — ปล่อยให้ตัวที่ไม่ได้ annotate กลายเป็น unknown ตั้งแต่ 6.0 function ที่ไม่ใช้ this เลยไม่ถูกนับเป็น context-sensitive อีก inference จึงเลือก T จาก callback ที่ annotate แล้วตัวหนึ่งแล้วไหลไปยังตัวอื่นได้:
declare function callIt<T>(obj: { produce: (x: number) => T; consume: (y: T) => void;}): void;
callIt({ consume(y) { return y.toFixed(); }, // y: number — inferred from produce produce(x: number) { return x * 2; },});ก่อน 6.0 y จะเป็น unknown ตรงนี้ถ้าไม่ annotate เอง กฎง่าย ๆ ยังเหมือนเดิม — annotate parameter เมื่อ inference มองไม่เห็นจริง ๆ — แค่ต้องทำน้อยลง
infer X extends constraint (4.7+)
หัวข้อที่มีชื่อว่า “infer X extends constraint (4.7+)”type variable ที่ถูก infer มี constraint ของตัวเองได้ กรองว่ามัน bind เป็นอะไรได้บ้าง แทนที่จะรับทุกอย่างที่ pattern match เจอ:
type FirstIfString<T> = T extends [infer F extends string, ...unknown[]] ? F : "not a string";
type A = FirstIfString<["a", 1, 2]>;// ^? "a"type B = FirstIfString<[1, 2, 3]>;// ^? "not a string" — the first element didn't satisfy `extends string`ถ้าไม่มี constraint infer F เดี่ยว ๆ จะ bind F เป็นอะไรก็ตามที่อยู่ตรงตำแหน่งนั้น — รวมถึง 1 ด้วย — แล้วคุณจะต้องมี conditional ที่สองแยกต่างหากเพื่อเช็คว่า F extends string infer F extends string รวมการเช็คนั้นเข้าไปใน pattern match เองเลย: ถ้า element แรกไม่ตรง constraint ทั้ง pattern จะ match ไม่ได้เลย แล้วตกไปที่ branch false ทันที
infer ข้างใน template literal type
หัวข้อที่มีชื่อว่า “infer ข้างใน template literal type”infer ทำงานข้างใน template literal pattern เหมือนกับที่ทำงานข้างใน pattern ของ function-shape หรือ array เป๊ะ — bind variable กับ substring ที่ match ช่องว่างใน template:
type ParseRoute<S> = S extends `/users/${infer Id}/posts/${infer PostId}` ? { id: Id; postId: PostId } : never;
type R = ParseRoute<"/users/42/posts/7">;// ^? { id: "42"; postId: "7" }นี่คือวิธีที่ tool เกี่ยวกับ route-typing, URL-parsing และ CSS-in-TS จำนวนมากทำงาน: string literal type เองก็คือ data structure และ infer ข้างใน template pattern คือกลไกในการ destructure มัน
return-type inference จาก context
หัวข้อที่มีชื่อว่า “return-type inference จาก context”inference site ไม่ได้มีแค่ตำแหน่ง argument — type ที่ value ถูก assign เข้าไป ก็ seed type parameter ได้ เมื่อไม่มี argument ให้ infer จากที่อื่น:
function make<T>(): T[] { return []; }
const arr: string[] = make();// ^? string[] — T inferred as string from the assignment's contextual type, not from any argumentโดยไม่มี argument เลย make() ต้องได้ T มาจากที่ไหนสักที่ TypeScript ดูว่าผลลัพธ์ถูกใช้เป็นอะไร — ตรงนี้คือ annotation บน arr — แล้วใช้เป็น inference site เป็นทางเลือกสุดท้าย ถ้าไม่มีแม้แต่ contextual type ให้ตกไปพึ่ง T ก็ไม่มีอะไรให้ infer จริง ๆ และจบที่ unknown — ผลลัพธ์ “ไม่มีอะไรให้ infer” แบบเดียวกับที่บท Generic Classes โชว์ให้ new Stack():
function make<T>(): T[] { return []; }
const loose = make();// ^? unknown[] — no argument, no contextual type — nothing to infer T frominference priority: argument ชนะ contextual type เสมอ
หัวข้อที่มีชื่อว่า “inference priority: argument ชนะ contextual type เสมอ”เมื่อมีทั้ง argument และ contextual (expected) type อยู่ argument จะชนะ — contextual type แค่เติมช่องว่างเท่านั้น มันไม่เคย override candidate ที่ inference มีอยู่แล้ว:
function wrap<T>(value: T): T[] { return [value]; }
const direct = wrap(1);// ^? number[] — T comes from the argument: 1
const strict: (string | number)[] = wrap(1);// ^? (string | number)[] as far as `strict`'s own annotation is concerned,// but wrap's inferred T is still number — the annotation widens the// *variable*, it does not change what T was inferred as inside wrapwrap(1) infer T เป็น number ทั้งสองครั้ง — annotation บน strict มีผลแค่หลังจากที่ wrap return number[] มาแล้ว ทำให้ array ที่ return มาถูกปฏิบัติแบบกว้างขึ้นในภายหลัง มันไม่เคยย้อนกลับเข้าไปใน inference ของ wrap แล้วขอ T ตัวอื่น
สูตร debug: “ทำไม infer เป็น string ไม่ใช่ literal?”
หัวข้อที่มีชื่อว่า “สูตร debug: “ทำไม infer เป็น string ไม่ใช่ literal?””ความสับสนที่พบบ่อย: generic ที่ควรรักษา literal type ไว้กลับ return ตัวที่ widen แล้ว และ generic function เองถูกตำหนิ ปกติแล้ว literal หายไปก่อนที่ generic จะเห็นมันด้วยซ้ำ:
function identity<T>(x: T): T { return x; }
let mode = "dark"; // let widens the initializer to string immediatelyconst r1 = identity(mode);// ^? string — too late: mode was already widened to string at its own declarationจุดสังเกต: as const ที่ call site แก้ไม่ได้ และเหตุผลที่มันแก้ไม่ได้ก็เป็นตัว diagnostic เอง — as const รับแค่ literal expression ไม่ใช่ reference ไปยัง mutable variable:
// @expect-error TS1355function identity<T>(x: T): T { return x; }let mode = "dark";const r1b = identity(mode as const);ถ้า mode as const compile ไม่ผ่านด้วยซ้ำ นั่นคือ compiler กำลังยืนยันว่าไม่เหลือ literal ให้รักษาตรงจุดนั้นแล้ว — mode เป็น string เต็มตัวและถาวรตั้งแต่ตอนที่ประกาศ ทางแก้จริงสองทาง และทางที่ดูเหมือนจะแก้แต่ไม่แก้อีกหนึ่ง:
function identity<T>(x: T): T { return x; }
// fix 1: declare with const, so the literal is never widened away in the first placeconst literalMode = "dark";const r2 = identity(literalMode);// ^? "dark"
// fix 2: keep the let, but pass a fresh literal expression through `as const`let mode2 = "dark";const r3 = identity("dark" as const);// ^? "dark"// @expect-error TS2345// non-fix: naming the type argument explicitly does NOT rescue an// already-widened source — mode2 is `string`, and `string` isn't// assignable to the narrower `"dark"` this now demandsfunction identity<T>(x: T): T { return x; }let mode2 = "dark";const r4 = identity<"dark">(mode2);สูตร: เมื่อ generic “ทำ literal หาย” ให้มองย้อนไปต้นทางก่อนถึง call ไม่ใช่ที่ signature ของ generic หาว่า value ถูกประกาศตรงไหน แล้วเช็คว่ามันเป็น let (widen ทันที) หรือ mutable array/object literal (widen element/property ของมัน) ก่อนที่ value จะมาถึง generic call const T (จากบทก่อนหน้า) และ as const ต่างรักษาแค่ literal ที่ยังมองเห็นได้ตอนที่มันรัน — ทั้งคู่ย้อนกลับไปกู้อันที่หายไปแล้วไม่ได้
infer destructuring a route out of a string literal type
type ParseRoute<S> = S extends `/users/${infer Id}/posts/${infer PostId}` ? { id: Id; postId: PostId } : never;
type R = ParseRoute<"/users/42/posts/7">;// ^? { id: "42"; postId: "7" }
// try changing the literal type below and re-running Check typestype Bad = ParseRoute<"/users/42">;// ^? never -- the pattern doesn't match at all การปรับปรุงอื่นใน 5.9
หัวข้อที่มีชื่อว่า “การปรับปรุงอื่นใน 5.9”- inference เข้มขึ้น การแก้ bug ที่ type variable “รั่ว” ออกจาก inference ทำให้บาง call ที่เคยยอมรับ
Tผิด ๆ กลายเป็น error ถ้า call ไหน compile ไม่ผ่านหลังอัปเกรด วิธีแก้ที่ตั้งใจไว้คือระบุ type argument ให้ชัด:f<Expected>(...)นี่คือการเปลี่ยนพฤติกรรม ไม่ใช่สิ่งที่คุณเปิด/ปิดเอง — ไม่มี flag ชื่อstrictInferenceใน TypeScript 7.0.2 (เช็คกับtsc --help --allแล้ว) inference ที่เข้มขึ้นแค่มาพร้อม inference ปกติเลย - instantiation เร็วขึ้น instantiation ระหว่างทางถูก cache เป็นเหตุผลที่ generic library หนัก ๆ (Zod, tRPC) เจอ error “excessively deep” น้อยลงมาก
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ผลลัพธ์ inference ที่ “เซอร์ไพรส์” ทุกอันในบทนี้ยุบลงมาเป็นการอ่านแบบเดียวกัน: หาทุกตำแหน่งที่ T ปรากฏ เรียงตาม priority ข้างบน แล้วถามว่าตัวแรกที่มีคำตอบเสนออะไรจริง ๆ — type ที่ widen แล้ว เพราะต้นทาง widen ไปก่อนมาถึง หรือ literal เพราะมันยังไม่ทันถูก widen
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| เครื่องมือ | ข้อดี | ต้นทุน |
|---|---|---|
| ปล่อยให้ inference ทำงาน (ไม่ระบุ type argument) | เขียนน้อยกว่า ยังถูกต้องแม้ call site เปลี่ยน | เลือก candidate ที่ไม่คาดคิด (literal, unknown, union กว้าง) แบบเงียบ ๆ โดยไม่มีสัญญาณที่ call site |
ระบุ type argument ตรง ๆ (f<T>(...)) | ตัด ambiguity เรื่อง T ทั้งหมดออก | ต้องดูแลให้ตรงกับมือเองถ้า constraint จริงของ function เปลี่ยน |
infer X extends C | รวมการเช็ค shape เข้าไปใน pattern match เอง — conditional เดียวแทนสอง | ใช้ได้แค่กับ site ของ infer ไม่ช่วยนอก conditional type |
| contextual (expected-type) inference | ให้ generic call ที่ตำแหน่ง “leaf” (ไม่มี argument) ยัง infer อะไรที่มีประโยชน์ได้ | ตกไปที่ unknown แบบเงียบ ๆ ทันทีที่ไม่มี contextual type ด้วย — พลาดง่าย |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- annotate return type ของ call เปลี่ยนสิ่งที่ type parameter ของ generic function ถูก infer เป็น มันไม่ย้อนกลับเข้าไปใน call — มีแค่ตำแหน่ง argument และถ้าไม่มีก็ contextual type ที่ call site เองเท่านั้นที่ป้อน inference annotation บนตัวแปรที่ผลลัพธ์ถูก widen เข้าไปทีหลังไม่เปลี่ยนอะไรต้นทางเลย
as constที่ call site ช่วยกู้ value ที่ widen แล้วได้เสมอas constรับแค่ literal expression reference ไปยังletvariable ที่ widen ไปแล้วตอนประกาศ compile ไม่ผ่านเป็นas constเลยด้วยซ้ำ (TS1355) — ความล้มเหลวนั้นเองคือหลักฐานว่า literal หายไปแล้ว ไม่ใช่ case ที่ต้องหาทางเลี่ยงด้วย cast- ระบุ type argument ตรง ๆ (
f<"dark">(x)) แก้ value ที่ widen ไปแล้ว มันเปลี่ยนสิ่งที่ต้องการ ไม่ใช่สิ่งที่ argument เป็นจริง ๆ ถ้าxเป็นstringการเรียกร้องให้Tเป็น"dark"ก็แค่สร้าง error ใหม่ที่ไม่เกี่ยวกัน (TS2345) แทนที่จะกู้ literal กลับมา - conditional type ที่ไม่มี
infersite ตรงเลยแค่ปล่อย variable ไว้ไม่ set มันไม่ทำแบบนั้น — ถ้า pattern โดยรวม match ไม่ได้เลย conditional ทั้งอันจะไป branchfalseและinfervariable ไม่เคย bind เป็นอะไรเลย ไม่ว่าส่วนอื่นของ pattern จะ “ดูเหมือน” match หรือไม่
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”inferred type ยาว ๆ ใน error message จริงพบบ่อยเมื่อ generic กับ object literal มารวมกัน และการอ่านมันคือทักษะ ไม่ใช่ปัญหาการสแกน:
error TS2322: Type '{ id: number; name: string; email: string; roles: string[]; active: boolean; }' is not assignable to type '{ id: string; }'. Types of property 'id' are incompatible. Type 'number' is not assignable to type 'string'.อันนี้มาจาก identity({ id: 1, name: "Ada", email: "[email protected]", roles: ["admin", "user"], active: true }) ที่ถูก assign ให้ตัวแปร type เป็น { id: string } compiler กำลังโชว์คุณ ตรงตัวเป๊ะ ว่า T ถูก infer เป็นอะไรจาก object literal argument — อ่านมันทีละ property เหมือน object type อื่น ๆ อย่ากลัวเพราะความยาวของมัน ความไม่ตรงกันจริงฝังอยู่ในบรรทัดที่สอง: id: number เทียบกับ id: string ของ target ทุกอย่างก่อนหน้านั้นคือ compiler รายงาน shape ที่มันสรุปได้อย่างซื่อสัตย์ ไม่ใช่ส่วนของปัญหา
ตอน review โค้ด generate ควรระวังทิศตรงข้ามด้วย: generic call ที่ inferred type ใหญ่ผิดปกติหรือ union กว้างผิดปกติ เป็นสัญญาณว่า literal บางตัวต้นทาง widen เร็วกว่าที่ตั้งใจ หรือ inference site ที่ผู้เขียนไม่คาดคิด (default parameter, fallback value) ป้อน candidate ที่ไม่ควรป้อน — คือ shape เดียวกับที่ NoInfer<T> มีไว้แก้เป๊ะ
🐛 หา bug ในโค้ดนี้
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {return { colors, defaultColor };}
const config = createStreetLight(["red", "yellow", "green"], "off");function isValidColor(c: string): c is "red" | "yellow" | "green" {return config.colors.includes(c as "red" | "yellow" | "green");}เฉลย
โค้ดนี้ compile ผ่านโดยไม่มี error เลย แต่ config.defaultColor ไม่ใช่หนึ่งในสามสีจริงเลย defaultColor?: C เป็น inference site ของ C เหมือน colors: C[] เป๊ะ ดังนั้นส่ง "off" เข้าไปไม่ถูกปฏิเสธ — มัน widen C เป็น "red" | "yellow" | "green" | "off" และ config.colors ถูก infer เป็น union ที่ widen แล้วตัวเดียวกัน ไม่ใช่แค่สามสีจริง return type ของ type predicate ของ isValidColor ยังอ้างแค่ "red" | "yellow" | "green" แต่ไม่มีอะไรตรวจคำอ้างนั้นกับ C ที่ widen ไปแล้ว — ทางแก้จาก section NoInfer ของบทนี้ใช้ได้ตรง ๆ: annotate fallback parameter เป็น defaultColor?: NoInfer<C> เพื่อให้มันถูกเช็คกับ C ที่ infer จาก colors เพียงอย่างเดียว แทนที่จะป้อน candidate ของตัวเอง