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

Inference & infer

เมื่อคุณเรียก 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 TS2345
function both<T>(a: T, b: T): T { return a; }
const y = both(1, "two"); // the first argument fixes T = 1, so "two" doesn't fit

argument แรกกลายเป็น 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 ออกมา

ภายใน 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>; // number
type B = MyReturnType<(x: string) => User>; // User
type 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 ทันทีตอนใช้ครั้งแรก)

infer จับตำแหน่งไหนใน structure ก็ได้ — element type, promise value, argument type:

// element type of an array
type ElementType<T> = T extends (infer E)[] ? E : T;
type E1 = ElementType<number[]>; // number
type E2 = ElementType<string>; // string (unchanged — not an array)
// the value a Promise resolves to
type Awaited1<T> = T extends Promise<infer V> ? V : T;
type V1 = Awaited1<Promise<string>>; // string
// the first parameter type
type 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

ทุกที่ที่ 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 TS2345
function 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 แล้ว)

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 มองไม่เห็นจริง ๆ — แค่ต้องทำน้อยลง

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 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 มัน

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 from

เมื่อมีทั้ง 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 wrap

wrap(1) infer T เป็น number ทั้งสองครั้ง — annotation บน strict มีผลแค่หลังจากที่ wrap return number[] มาแล้ว ทำให้ array ที่ return มาถูกปฏิบัติแบบกว้างขึ้นในภายหลัง มันไม่เคยย้อนกลับเข้าไปใน inference ของ wrap แล้วขอ T ตัวอื่น

ความสับสนที่พบบ่อย: 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 immediately
const 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 TS1355
function 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 place
const 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 demands
function 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 types
type Bad = ParseRoute<"/users/42">;
// ^? never -- the pattern doesn't match at all
Open in TS Playground
  • 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” น้อยลงมาก

ผลลัพธ์ 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 ไปยัง let variable ที่ 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 ที่ไม่มี infer site ตรงเลยแค่ปล่อย variable ไว้ไม่ set มันไม่ทำแบบนั้น — ถ้า pattern โดยรวม match ไม่ได้เลย conditional ทั้งอันจะไป branch false และ infer variable ไม่เคย bind เป็นอะไรเลย ไม่ว่าส่วนอื่นของ pattern จะ “ดูเหมือน” match หรือไม่

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 ของตัวเอง

TypeScript เอาค่าของ type parameter ของ generic function มาจากไหน?
infer R ใน T extends (...args: any[]) => infer R ? R : never ทำอะไร?
จะดึง element type ออกจาก array type อย่างไร?
การใส่ constraint ตรง ๆ บน infer variable แบบ infer F extends string (TypeScript 4.7+) ทำอะไร?
r1 ได้ type อะไร และทำไมการห่อ call เป็น identity(mode as const) ถึงแก้ไม่ได้?
function identity<T>(x: T): T { return x; }
let mode = "dark";
const r1 = identity(mode);
arr กับ loose ได้ type อะไร และทำไมถึงต่างกัน?
function make<T>(): T[] { return []; }
const arr: string[] = make();
const loose = make();