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

Template Literal Types

template literal type หน้าตาเหมือน template string ของ JavaScript แต่สร้าง type คุณ interpolate type อื่นเข้าไปใน type ที่มีรูปเป็น string

// @expect-error TS2322
type 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 เลย

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 names
type 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"]
union distribute บน template literal type

พลังจริง ๆ มาเมื่อคุณรวม template literal กับ conditional type และ infer: คุณ destructure string type ได้ โดย capture ส่วนหนึ่งของตัวเองเข้าไปใน type variable ใหม่

// Capture the event name after the "on" prefix
type 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 parts
type 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 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 half

recursive 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 ที่ interpolate แต่ละตัวคูณจำนวนการผสม literal ทั้งหมดเข้าด้วยกัน union สองตัวที่มี 10 member ไม่ได้ให้แค่ 20 ความเป็นไปได้ — มันให้ 100 และการเติบโตเป็นแบบคูณกันทุกจุด interpolate ที่เพิ่มเข้ามา:

// @expect-error TS2590
type 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 ตัวที่เป็นไปได้”

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 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"

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 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 /" instead
type 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: 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", ",">;
type _splitCheck = Expect<Equal<R, ["a", "b", "c"]>>;
// event map built from mapped-type key remapping + template literal
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>;
Open in TS Playground
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"]
A template literal's union size multiplies; [T] wrapping tests the whole union at once

มองทุกจุด 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 uniondistribute เป็น 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 (หรือ constraint K extends string) คือสิ่งที่ทำให้ compiler ยอมรับ
  • 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 routes
function 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 กำลังซ่อนไว้

type ${Lang}-${Kind} ที่ Lang = "en" | "th" และ Kind = "a" | "b" คืออะไร?
Capitalize<"click"> ให้อะไร?
ใน T extends \on${infer E}\ ? E : never infer E ทำอะไร?
ใน TypeScript 7 HeadTail<"😀abc"> ให้อะไร เมื่อ HeadTail ลอกตัวอักษรแรกด้วย ${infer Head}${infer Tail}?
EventName<"click"> คืนอะไรถ้า EventName<T> = T extends \on${infer E}\ ? E : never?
ทำไมอันนี้ compile ไม่ผ่าน?
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
type Big = `${Digit}${Digit}${Digit}${Digit}${Digit}`;
R คืออะไร และจะทำให้มันตอบ "union ทั้งก้อน match ไหม" แทนได้อย่างไร?
type StartsWithSlash<T extends string> = T extends `/${string}` ? true : false;
type R = StartsWithSlash<"/users" | "profile">;