Template Literal Types
string ที่คุณคำนวณได้
หัวข้อที่มีชื่อว่า “string ที่คุณคำนวณได้”template literal type หน้าตาเหมือน template string ของ JavaScript แต่สร้าง type คุณ interpolate type อื่นเข้าไปใน type ที่มีรูปเป็น string
// @expect-error TS2322type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, Ada"; // ✅const b: Greeting = "Hi, Ada"; // ❌ must start with "Hello, "interpolate union ของ literal เข้าไป แล้ว template จะ distribute บน union นั้น — สร้างทุกการผสมออกมาเป็น union ของ literal type:
type Lang = "en" | "th";type Kind = "page" | "post";
type Key = `${Lang}-${Kind}`;// ^? "en-page" | "en-post" | "th-page" | "th-post"union สองตัวที่มี 2 member ให้ literal type 4 ตัว นี่คือวิธีที่ library generate string key แบบเป๊ะ — CSS property, event name, i18n key — โดยไม่มี runtime code เลย
intrinsic string manipulator
หัวข้อที่มีชื่อว่า “intrinsic string manipulator”TypeScript มาพร้อม type built-in 4 ตัวที่ transform string literal type: Uppercase, Lowercase, Capitalize และ Uncapitalize
type Loud = Uppercase<"hello">; // "HELLO"type Cap = Capitalize<"name">; // "Name"
// Combine with a template to build event handler namestype Handler<E extends string> = `on${Capitalize<E>}`;type ClickHandler = Handler<"click">; // "onClick"flowchart LR u["union: en or th"] -->|"interpolate into 'X-page'"| out["en-page or th-page"]
pattern matching ด้วย infer
หัวข้อที่มีชื่อว่า “pattern matching ด้วย infer”พลังจริง ๆ มาเมื่อคุณรวม template literal กับ conditional type และ infer: คุณ destructure string type ได้ โดย capture ส่วนหนึ่งของตัวเองเข้าไปใน type variable ใหม่
// Capture the event name after the "on" prefixtype EventName<T> = T extends `on${infer E}` ? E : never;
type E1 = EventName<"onClick">; // "Click"type E2 = EventName<"onHover">; // "Hover"type E3 = EventName<"click">; // never — no "on" prefix
// Split a route into its partstype Method<T> = T extends `${infer M} ${string}` ? M : never;type M = Method<"GET /users">; // "GET"infer E บอกว่า “match อะไรก็ได้ตรงนี้แล้วเรียกว่า E” นี่คือการ parse จริง ๆ ที่ระดับ type — เป็นเทคนิคเบื้องหลัง typed router ที่ดึง :id param จาก path string และ library ที่ validate format string
TypeScript 7 กับ Unicode
หัวข้อที่มีชื่อว่า “TypeScript 7 กับ Unicode”จนถึง TypeScript 6 infer ใน template literal จะแบ่ง string ตาม UTF-16 code unit — หน่วยเดียวกับที่ .length ของ JavaScript นับ ซึ่งพังเงียบ ๆ กับ emoji และตัวอักษรนอก BMP TypeScript 7 เปลี่ยนมาแบ่งตาม Unicode code point:
type HeadTail<S extends string> = S extends `${infer Head}${infer Tail}` ? [Head, Tail] : never;
type R = HeadTail<"😀abc">;// ^? TypeScript 7: ["😀", "abc"]// TypeScript 6: ["\ud83d", "\ude00abc"] — a surrogate pair cut in halfrecursive string type ทุกตัวที่ลอกทีละตัวอักษร — Split, Reverse, ตัวนับจำนวนอักษร — ตอนนี้เห็นตัวอักษรเต็มตัว ซึ่งเกือบทุกกรณีคือสิ่งที่คุณต้องการอยู่แล้ว แต่มันเป็น breaking change ถ้า type ของคุณพึ่งพาการนับแบบเดิม: type “length” ระดับ type ที่เอาไปเทียบกับ .length ตอน runtime (ซึ่งยังนับ code unit) จะให้ค่าไม่ตรงกันกับ "😀" ถ้าคุณดูแล type แบบนี้อยู่ ทดสอบด้วย input นอก BMP ตอน upgrade
เจอที่ไหนบ้าง
หัวข้อที่มีชื่อว่า “เจอที่ไหนบ้าง”- typed key:
`${Theme}-${Shade}`สำหรับ type ของ design-token - event map: เปลี่ยน
"click"เป็น"onClick"สำหรับ prop type - route param: ดึง
idจาก"/users/:id"เป็น{ id: string } - ORM/query builder: parse column string เป็น typed shape
ใช้เทคนิคนี้ตรงที่ string encode structure จริง ๆ ถ้าใช้กระตือรือร้นเกินไปคุณจะได้ type ที่เพื่อนร่วมทีมอ่านไม่ออก — เป็น trade-off ที่กลับมาเสมอของ type-level programming
union cross-product ระเบิดได้: TS2590
หัวข้อที่มีชื่อว่า “union cross-product ระเบิดได้: TS2590”union ที่ interpolate แต่ละตัวคูณจำนวนการผสม literal ทั้งหมดเข้าด้วยกัน union สองตัวที่มี 10 member ไม่ได้ให้แค่ 20 ความเป็นไปได้ — มันให้ 100 และการเติบโตเป็นแบบคูณกันทุกจุด interpolate ที่เพิ่มเข้ามา:
// @expect-error TS2590type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";type Big = `${Digit}${Digit}${Digit}${Digit}${Digit}`;digit 10 ตัว interpolate 5 ครั้งคือ 10 ** 5 = 100,000 literal string type ที่แตกต่างกัน — มากพอที่จะข้าม union-size ceiling ของ TypeScript แล้วให้ TS2590: Expression produces a union type that is too complex to represent ทำซ้ำ 4 ครั้ง (10,000 การผสม) compile ผ่านสบาย ๆ — นี่คือเวอร์ชันที่เล็กที่สุดที่ reproduce error นี้ได้ หาเจอด้วยการนับ: 10 ** n ข้าม 100,000 ที่ n = 5 บทเรียนไม่ใช่ “หลีกเลี่ยง template literal ที่มี union” — แต่คือ “รู้ว่าขนาดคือผลคูณ (∏) ของจำนวน member ของแต่ละ union ที่ interpolate ไม่ใช่ผลบวก” ก่อนจะหยิบ template literal type มาใช้ encode อะไรอย่าง “ทุกโค้ด alphanumeric 5 ตัวที่เป็นไปได้”
ปิด distribution ด้วย [T] ใน conditional ที่อยู่ข้าง template
หัวข้อที่มีชื่อว่า “ปิด distribution ด้วย [T] ใน conditional ที่อยู่ข้าง template”generic template literal type distribute บน union แบบเดียวกับ construct อื่นที่อยู่ข้าง conditional เมื่อ type parameter เป็น naked:
type StartsWithSlash<T extends string> = T extends `/${string}` ? true : false;
type Result = StartsWithSlash<"/users" | "profile">;// ^? boolean -- one branch per union member, unioned"/users" match แล้วให้ true; "profile" ไม่ match ให้ false; union กันได้ boolean — ไม่ใช่คำตอบใช่/ไม่ใช่ที่มีประโยชน์เกี่ยวกับ union ทั้งก้อน idiom [T] extends [U] แบบเดียวกับบท Conditional Types แก้ปัญหานี้ได้ด้วย ตรวจ union ทั้งก้อนกับ template pattern ทีเดียวแทนที่จะทีละ member:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
type StartsWithSlashAll<T extends string> = [T] extends [`/${string}`] ? true : false;
type NotAll = StartsWithSlashAll<"/users" | "profile">;type _notAll = Expect<Equal<NotAll, false>>;
type AllSlash = StartsWithSlashAll<"/users" | "/profile">;type _allSlash = Expect<Equal<AllSlash, true>>;StartsWithSlashAll ตอบคำถาม “memberทุกตัวของ union นี้ขึ้นต้นด้วย / ไหม” ซึ่งเป็นคำถามที่ต่างไปจาก (และมีประโยชน์กว่าในบางกรณี) “memberตัวนี้*ขึ้นต้นด้วย / ไหม”
infer ข้างใน template แบบ recursive: Split
หัวข้อที่มีชื่อว่า “infer ข้างใน template แบบ recursive: Split”รวม infer ข้างใน template pattern กับ recursion แล้วคุณจะได้การ parse string จริง ๆ — ตัวอย่างคลาสสิกคือแบ่ง string ที่มี delimiter เป็น tuple ของแต่ละส่วน เวอร์ชันระดับ type ของ "a,b,c".split(","):
type Split<S extends string, D extends string> = S extends `${infer Head}${D}${infer Rest}` ? [Head, ...Split<Rest, D>] : [S];
type R = Split<"a,b,c", ",">;// ^? ["a", "b", "c"]แต่ละขั้น capture ทุกอย่างก่อน delimiter ถัดไปเป็น Head แล้ว recurse บน Rest; base case (ไม่เจอ delimiter อีกแล้ว) ห่อสิ่งที่เหลือไว้ใน tuple element เดียว Join คือภาพสะท้อนกัน — ต่อ tuple ของ string กลับเป็น string เดียวแบบ recursive โดยมี separator คั่นแต่ละคู่ — และ Trim ก็คือแนวคิด “ลอกทีละตัวอักษร” เดียวกัน แค่ใช้กับ space ที่หัว/ท้ายแทน delimiter:
type TrimLeft<S extends string> = S extends ` ${infer Rest}` ? TrimLeft<Rest> : S;type TrimRight<S extends string> = S extends `${infer Rest} ` ? TrimRight<Rest> : S;type Trim<S extends string> = TrimLeft<TrimRight<S>>;
type T1 = Trim<" hello ">;// ^? "hello"pattern-match string key เข้าไปเป็น event map
หัวข้อที่มีชื่อว่า “pattern-match string key เข้าไปเป็น event map”key remapping ด้วย as ของ mapped type จากบทก่อนหน้ากับ template literal interpolation รวมกันได้เป็นธรรมชาติ สร้าง typed event-handler map จาก interface ที่แค่ map ชื่อ event ไปหา payload — pattern ที่คุณจะจำได้จาก typed DOM หรือ UI event system ทุกตัว:
interface EventMap { click: MouseEvent; hover: PointerEvent;}
type OnHandlers<T> = { [K in keyof T as `on${Capitalize<string & K>}`]: (event: T[K]) => void;};
type Handlers = OnHandlers<EventMap>;// ^? { onClick: (event: MouseEvent) => void; onHover: (event: PointerEvent) => void }Capitalize<string & K> ต้องมี string & เพราะ K ไล่ผ่าน keyof T ซึ่งรู้แค่ว่า assignable ให้ string | number | symbol เท่านั้นโดยทั่วไป — intersect กับ string บอก compiler ว่า “ให้ปฏิบัติกับมันเป็น string เพื่อจุดประสงค์ของ Capitalize ด้วย” ซึ่งปลอดภัยตรงนี้เพราะ key ทุกตัวของ EventMap เป็น string literal จริง ๆ
template literal บวก satisfies สำหรับ route table
หัวข้อที่มีชื่อว่า “template literal บวก satisfies สำหรับ route table”template literal type เป็น value type ของ Record รวมกับ satisfies validate ทุก entry ใน route table เทียบกับ shape (ขึ้นต้นด้วย /) พร้อมทั้งคง literal type ของแต่ละ route ไว้ใช้ต่อ — ประโยชน์เดียวกับ satisfies จากบท Literals & Widening แค่ใช้กับทั้ง table ทีเดียว:
const routes = { home: "/", userProfile: "/users/:id", postDetail: "/posts/:id/comments/:commentId",} satisfies Record<string, `/${string}`>;
// Each entry keeps its own literal type, not the widened `` `/${string}` `` template.type HomeRoute = typeof routes.home;// ^? "/"ถ้า route value ตัวไหนถูกเปลี่ยนเป็น "users/:id" (ขาด / ข้างหน้า) การเช็ค satisfies เทียบกับ Record ที่ value type เป็น template literal แบบข้างบนจะจับได้ทันที — annotation ธรรมดา : Record<string, string> จับไม่ได้ และ as cast ก็จับไม่ได้เหมือนกัน
Union cross-products, [T] suppression, and recursive Split with infer
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
// distribution gives boolean for "does this member start with /"type StartsWithSlash<T extends string> = T extends `/${string}` ? true : false;type Result = StartsWithSlash<"/users" | "profile">;
// [T] suppression answers "does the WHOLE union start with /" insteadtype StartsWithSlashAll<T extends string> = [T] extends [`/${string}`] ? true : false;type NotAll = StartsWithSlashAll<"/users" | "profile">;type _notAll = Expect<Equal<NotAll, false>>;
// recursive infer inside a template: Splittype Split<S extends string, D extends string> = S extends `${infer Head}${D}${infer Rest}` ? [Head, ...Split<Rest, D>] : [S];
type R = Split<"a,b,c", ",">;type _splitCheck = Expect<Equal<R, ["a", "b", "c"]>>;
// event map built from mapped-type key remapping + template literalinterface EventMap { click: MouseEvent; hover: PointerEvent; }type OnHandlers<T> = { [K in keyof T as `on${Capitalize<string & K>}`]: (event: T[K]) => void };type Handlers = OnHandlers<EventMap>; Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart TD
interp["จุด interpolate union แต่ละจุด"] --> mult["คูณจำนวนการผสมทั้งหมด"]
mult --> limit{"รวมแล้วเกิน ~100,000 ไหม?"}
limit -->|เกิน| ts2590["TS2590: too complex to represent"]
naked["T ที่ naked ถูกตรวจกับ template pattern"] --> perMember["distribute: กิ่งละ member"]
wrapped["[T] extends [pattern]"] --> whole["ตรวจ union ทั้งก้อนเป็น type เดียว -- ไม่ distribute"] มองทุกจุด interpolate ใน template literal type เหมือนเป็นการคูณ ไม่ใช่บวก — mental model เดียวกับที่อธิบายว่าทำไม TS2590 โผล่เร็วกว่าที่สัญชาตญาณบอก มองว่า conditional ที่สร้างรอบ template pattern เหมือน conditional ธรรมดาทุกตัว: naked distribute ทีละ member, [T] ตรวจ union ทั้งก้อนทีเดียว — กลไกไม่ได้เกี่ยวกับ string เป็นพิเศษเลย มันคือกฎ distribution เดียวกับบท Conditional Types แค่มี template pattern เป็นฝั่ง U
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
| interpolate หลาย union ใน template เดียว | generate ทุกการผสมที่ valid อัตโนมัติ — ไม่ต้อง enumerate มือ | จำนวนการผสมคูณกัน ไม่ใช่บวก — union ขนาดกลางไม่กี่ตัวก็ชน TS2590 ได้เร็วกว่าที่คาด |
T naked เทียบกับ template pattern | ตอบ “memberนี้match ไหม” ทีละตัว มีประโยชน์สำหรับ filter union | distribute เป็น boolean ทั้งที่คุณอยากได้คำตอบเดียวสำหรับทั้ง union |
[T] extends [pattern] | ตอบ “unionทั้งก้อนmatch ไหม” คำตอบเดียวเด็ดขาด | เสียรายละเอียดทีละ member ไป — ถ้าต้องการทั้งสองแบบ ต้องเขียนทั้งสอง form |
recursive infer ข้างใน template (Split, Trim) | parse string จริง ๆ ที่ประเมินทั้งหมดตอน compile time | นำ recursion-depth ceiling จากบท Recursive Types กลับมาสำหรับ string ที่ยาวมาก ๆ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- interpolate union สองตัวที่มี 10 member ให้ 20 string ที่เป็นไปได้ ไม่จริง มันให้
10 × 10 = 100— ขนาด union คูณกันข้ามจุด interpolate ไม่ใช่บวก ซึ่งเป็นเหตุผลเป๊ะที่TS2590โผล่มาได้กับ union ที่ดูขนาดพอประมาณ - conditional ของ generic template literal type อย่าง
T extends `/${string}` ? true : falseบอกเรื่อง union ทั้งก้อนเสมอ มัน distribute บนTที่ naked ให้กิ่งละ member แล้ว union เข้าด้วยกัน — สำหรับ union ผสมนั่นคือbooleanไม่ใช่คำตอบเดียวที่มีความหมาย[T] extends [pattern]คือทางแก้เมื่อคุณอยากได้คำถามแบบ whole-union แทน Capitalize<K>ที่Kเป็นkeyof Tไม่ต้องทำอะไรเพิ่มkeyof Tรู้แค่ว่า assignable ให้string | number | symbolเท่านั้นโดยทั่วไป และ intrinsic การจัดการ string ต้องการ type ที่ assignable ให้stringจริง ๆ —string & K(หรือ constraintK extends string) คือสิ่งที่ทำให้ compiler ยอมรับ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- type ที่ generate มา interpolate union เล็ก ๆ หลายตัวเพื่อสร้าง enumeration “ครบถ้วน” ของ string ที่ valid (product code, permission string, hex-like identifier) มีความเสี่ยง
TS2590ที่จะไม่โผล่ตอน review มือเร็ว ๆ ที่แค่นับจำนวน member ของ union — คูณมันก่อนจะสมมติว่าปลอดภัย - conditional ที่สร้างจาก template pattern (
T extends `prefix-${string}` ? ... : ...) ที่ใช้กับ union แล้วคาดหวังว่าจะ gate “list ทั้งหมดนี้ okay ไหม” เป็นกับดักที่ AI generate บ่อย: มัน distribute แล้วให้booleanยอมรับ call site แบบเงียบ ๆ แทนที่จะ reject union ที่มี member ผิดหนึ่งตัว เพราะbooleanมี shape ที่ truthy พอจะ type-check ผ่านกับโค้ดรอบข้างส่วนใหญ่โดยไม่มีคำเตือน Record<string, T>ที่ใช้กับ route/permission/config table โดยไม่มีsatisfiesเลย เสีย literal type ที่แม่นยำของทุก entry ไป และไม่ validate shape อะไรเลย — ถ้าโค้ดทีหลังพึ่งพา route string type เจาะจง (หรือลืมใส่/นำหน้า) ไม่มีอะไรใน type system จะจับได้
🐛 หา bug ในโค้ดนี้
type ApiRoute = "/users" | "/posts" | "/comments";
type IsValidRoute<T extends string> = T extends ApiRoute ? true : false;
// "validate" that every route a client requests is one of our known routesfunction checkRoutes<T extends string>(routes: T[]): IsValidRoute<T> {return routes.every((r) => (["/users", "/posts", "/comments"] as string[]).includes(r)) as IsValidRoute<T>;}
const result = checkRoutes(["/users", "/unknown"]);เฉลย
IsValidRoute<T> distribute บน T เพราะ T naked อยู่ฝั่งที่ถูกตรวจ ดังนั้นสำหรับ T = "/users" | "/unknown" มัน evaluate เป็น true | false — boolean ธรรมดา — ไม่ใช่คำตอบเดียวที่เด็ดขาดว่า call ทั้งอัน valid ไหม return type ของ function จบลงด้วย boolean สำหรับ call นี้ไม่ว่ายังไง ดังนั้น result type-check ผ่านสบายทั้งสองทาง และ cast as IsValidRoute<T> ตอนท้ายก็เงียบความไม่ตรงกันระหว่างการเช็คจริงตอน runtime (.every(...) ซึ่งคำนวณคำตอบ true/false เดียวสำหรับทั้ง array จริง ๆ) กับสิ่งที่ type system เชื่อว่า return type เป็น การแก้ type ให้ตรงกับความตั้งใจ — คำตอบเด็ดขาดเดียวสำหรับทั้ง array — คือห่อฝั่งที่ถูกตรวจ: type IsValidRoute<T extends string> = [T] extends [ApiRoute] ? true : false; พอแก้แล้ว IsValidRoute<"/users" | "/unknown"> resolve เป็น literal false ตัวเดียวถูกต้อง (เพราะ "/unknown" ไม่ใช่ส่วนหนึ่งของ ApiRoute) ตรงกับสิ่งที่ .every(...) คำนวณจริงตอน runtime แทนที่จะให้ type แบบ always-boolean แบบเงียบ ๆ ที่ cast as กำลังซ่อนไว้