Template literal types trong TypeScript qua ví dụ
1. Tổng quan
Template literal types cho phép bạn dùng cú pháp backtick ngay trong hệ thống kiểu của TypeScript. Nhờ đó bạn có thể ghép, biến đổi và phân tích các kiểu chuỗi ngay lúc compile. Bài viết này đi qua từng tính năng bằng ví dụ cụ thể: ghép union, đổi tên key trong mapped type, trích tham số từ route và viết event emitter an toàn kiểu. Đọc xong, bạn sẽ biết khi nào nên dùng kỹ thuật này. Bạn cũng sẽ biết cách kiểm thử nó và những chỗ dễ làm compiler chậm hoặc báo lỗi khó hiểu.
Mọi ví dụ trong bài dùng TypeScript 5.x với strict: true.
2. Cú pháp cơ bản
Một template literal type trông giống hệt template string ở runtime. Điểm khác là phần nội suy ${...} chứa kiểu, không chứa giá trị:
type OrderId = `ORD-${number}`;
const ok: OrderId = "ORD-1024";
// const bad: OrderId = "INV-1024";
// Error: Type '"INV-1024"' is not assignable to type '`ORD-${number}`'.
Kiểu OrderId chấp nhận mọi chuỗi bắt đầu bằng ORD- và theo sau là một chuỗi biểu diễn số. Bạn có thể nội suy string, number, bigint, boolean, null, undefined và các literal type tương ứng.
Một ví dụ quen thuộc là giá trị CSS có đơn vị:
type Pixel = `${number}px`;
function setWidth(el: HTMLElement, width: Pixel): void {
el.style.width = width;
}
setWidth(document.body, "320px"); // OK
// setWidth(document.body, "320"); // Error: thiếu "px"
Compiler giờ bắt được lỗi quên đơn vị. Trước đây lỗi này chỉ lộ ra khi bạn mở trình duyệt.
3. Kết hợp với union: tích Descartes
Khi phần nội suy là một union, TypeScript phân phối template qua từng thành viên. Nếu có nhiều union, kết quả là mọi tổ hợp có thể:
type Size = "sm" | "md" | "lg";
type Variant = "primary" | "danger";
type ButtonClass = `btn-${Size}-${Variant}`;
Di chuột lên ButtonClass trong editor, bạn sẽ thấy:
type ButtonClass = "btn-sm-primary" | "btn-sm-danger" | "btn-md-primary"
| "btn-md-danger" | "btn-lg-primary" | "btn-lg-danger"
Ở đây 3 × 2 = 6 thành viên được sinh tự động. Khi thêm "xl" vào Size, ButtonClass tự cập nhật. Bạn không phải sửa thêm chỗ nào.
Lưu ý: Số tổ hợp tăng theo cấp số nhân. Nếu union kết quả vượt quá 100.000 thành viên, TypeScript sẽ báo lỗi “Expression produces a union type that is too complex to represent”. Đừng ghép nhiều union lớn với nhau, ví dụ union 1.000 mã sản phẩm ghép với union 200 mã quốc gia.
4. Các kiểu biến đổi chuỗi có sẵn
TypeScript cung cấp bốn intrinsic type để đổi hoa/thường:
type Event = "orderCreated";
type A = Uppercase<Event>; // "ORDERCREATED"
type B = Lowercase<Event>; // "ordercreated"
type C = Capitalize<Event>; // "OrderCreated"
type D = Uncapitalize<"OrderCreated">; // "orderCreated"
Chúng được cài sẵn trong compiler, không phải viết bằng TypeScript. Bốn kiểu này thường đi kèm template literal để tạo tên theo quy ước, ví dụ on${Capitalize<K>} hay ${Uppercase<K>}_ENV.
type ConfigKey = "databaseUrl" | "apiToken";
type EnvName = `APP_${Uppercase<ConfigKey>}`;
// "APP_DATABASEURL" | "APP_APITOKEN"
5. Đổi tên key trong mapped type
Đây là chỗ template literal types phát huy nhiều nhất. Mệnh đề as trong mapped type cho phép bạn đổi tên từng key khi tạo kiểu mới.
5.1. Sinh getter từ một interface
interface Customer {
name: string;
email: string;
loyaltyPoints: number;
}
type Getters<T> = {
[K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};
type CustomerGetters = Getters<Customer>;
Kết quả:
type CustomerGetters = {
getName: () => string;
getEmail: () => string;
getLoyaltyPoints: () => number;
}
Giải thích từng phần:
keyof T & stringlọc bỏ key kiểunumbervàsymbol. Lý do làCapitalizechỉ nhậnstring. Nếu bỏ& string, bạn sẽ gặp lỗi “Type ‘K’ does not satisfy the constraint ‘string’”.as \get${Capitalize}` đổinamethànhgetName`.() => T[K]giữ đúng kiểu trả về của property gốc.
Một class hiện thực kiểu này sẽ bị compiler bắt lỗi ngay nếu thiếu getter hoặc trả sai kiểu:
class CustomerView implements CustomerGetters {
constructor(private readonly data: Customer) {}
getName = () => this.data.name;
getEmail = () => this.data.email;
getLoyaltyPoints = () => this.data.loyaltyPoints;
}
5.2. Lọc key bằng cách trả về never
Nếu biểu thức trong as cho ra never, key đó bị loại khỏi kết quả. Bạn có thể kết hợp điều này với template literal để chọn key theo tiền tố:
interface PaymentService {
chargeCard(amount: number): Promise<void>;
chargeWallet(amount: number): Promise<void>;
refund(paymentId: string): Promise<void>;
}
type ChargeMethods<T> = {
[K in keyof T as K extends `charge${string}` ? K : never]: T[K];
};
type OnlyCharge = ChargeMethods<PaymentService>;
// { chargeCard: ...; chargeWallet: ... }
refund không khớp mẫu charge${string} nên bị loại.
6. Phân tích chuỗi bằng infer
Template literal không chỉ dùng để ghép chuỗi. Bạn còn có thể dùng nó để tách chuỗi trong conditional type, kết hợp với infer.
6.1. Tách một phần đơn giản
type StripPrefix<S extends string> =
S extends `ORD-${infer Rest}` ? Rest : never;
type X = StripPrefix<"ORD-1024">; // "1024"
type Y = StripPrefix<"INV-1024">; // never
Khi khớp mẫu, TypeScript gán phần còn lại cho Rest.
6.2. Trích tham số từ route
Đây là ví dụ thực tế hay gặp nhất. Từ chuỗi "/users/:userId/orders/:orderId", ta muốn có kiểu "userId" | "orderId":
type ExtractParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: Path extends `${string}:${infer Param}`
? Param
: never;
type P = ExtractParams<"/users/:userId/orders/:orderId">;
// "userId" | "orderId"
Cách nó chạy:
- Nhánh đầu khớp
"/users/" + ":" + "userId" + "/" + "orders/:orderId". Mỗi placeholder khớp tới lần xuất hiện đầu tiên của ký tự literal đứng sau nó. Vì vậyParamlà"userId". - Kiểu đệ quy với
"/orders/:orderId". Lần này không còn/sau tham số nên nhánh đầu thất bại. - Nhánh thứ hai khớp và trả
"orderId". - Nếu chuỗi không có
:, kết quả lànever.
Giờ dùng nó cho một hàm build URL:
type RouteParams<Path extends string> = {
[K in ExtractParams<Path>]: string;
};
function buildUrl<Path extends string>(
path: Path,
params: RouteParams<Path>,
): string {
const values = params as Record<string, string>;
return path.replace(/:(\w+)/g, (_, key: string) =>
encodeURIComponent(values[key]),
);
}
const url = buildUrl("/users/:userId/orders/:orderId", {
userId: "42",
orderId: "ORD-7",
});
console.log(url);
// buildUrl("/users/:userId", { id: "42" });
// Error: Object literal may only specify known properties, and 'id' does not exist...
Output:
/users/42/orders/ORD-7
Generic Path được suy ra là literal type từ đối số đầu tiên. Nhờ vậy object params bị kiểm tra chính xác: thiếu key, thừa key hay gõ sai tên đều bị báo lỗi. Ép kiểu sang Record<string, string> ở thân hàm là cần thiết. TypeScript không thể chứng minh một key lấy ra từ regex lúc runtime thuộc ExtractParams<Path>.
7. Ví dụ tổng hợp: event emitter an toàn kiểu
Gộp các kỹ thuật trên lại, ta có một emitter cho domain đơn hàng:
type OrderEvents = {
created: { orderId: string; total: number };
shipped: { orderId: string; carrier: string };
cancelled: { orderId: string; reason: string };
};
type EventHandlers<E> = {
[K in keyof E & string as `on${Capitalize<K>}`]?: (payload: E[K]) => void;
};
class OrderEmitter {
constructor(private readonly handlers: EventHandlers<OrderEvents>) {}
emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
const name = `on${event[0].toUpperCase()}${event.slice(1)}` as
`on${Capitalize<K>}`;
const handler = this.handlers[name] as
((p: OrderEvents[K]) => void) | undefined;
handler?.(payload);
}
}
const emitter = new OrderEmitter({
onCreated: (p) => console.log(`Created ${p.orderId}: ${p.total}`),
onShipped: (p) => console.log(`Shipped via ${p.carrier}`),
});
emitter.emit("created", { orderId: "ORD-1", total: 250_000 });
emitter.emit("shipped", { orderId: "ORD-1", carrier: "GHN" });
// emitter.emit("shipped", { orderId: "ORD-1", reason: "x" }); // Error
Output:
Created ORD-1: 250000
Shipped via GHN
Những gì bạn có được:
- Tên handler
onCreated,onShipped,onCancelledsinh tự động từOrderEvents. - Tham số
ptrong mỗi handler được suy ra đúng kiểu payload, không cần khai báo. emit("shipped", ...)bắt buộc payload cócarrier.
Hai lần as trong emit là chỗ ta nói với compiler điều nó không tự suy được. Chuỗi được dựng lúc runtime không mang thông tin kiểu. Hãy giữ những chỗ ép kiểu như vậy trong phạm vi nhỏ, nằm trong phần hiện thực. API công khai vẫn an toàn hoàn toàn.
8. Kiểm thử kiểu với Vitest
Logic ở tầng kiểu cũng cần test. Nếu không, một lần refactor có thể biến ExtractParams thành string mà không ai phát hiện. Cài Vitest:
npm install -D vitest typescript
Viết test cho cả hành vi kiểu và hành vi runtime:
// route.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { buildUrl, type ExtractParams } from "./route";
describe("ExtractParams", () => {
it("trích đúng tên tham số", () => {
expectTypeOf<ExtractParams<"/users/:userId/orders/:orderId">>()
.toEqualTypeOf<"userId" | "orderId">();
});
it("trả never khi không có tham số", () => {
expectTypeOf<ExtractParams<"/health">>().toBeNever();
});
});
describe("buildUrl", () => {
it("thay tham số và encode giá trị", () => {
const url = buildUrl("/customers/:customerId", { customerId: "a b" });
expect(url).toBe("/customers/a%20b");
});
it("từ chối key sai", () => {
// @ts-expect-error - 'id' không phải tham số của route
buildUrl("/customers/:customerId", { id: "1" });
});
});
Chạy:
npx tsc --noEmit && npx vitest run
expectTypeOf là no-op lúc runtime. Lỗi của nó chỉ hiện ra khi type-check, nên bước tsc --noEmit (hoặc vitest --typecheck) là bắt buộc. Chỉ thị @ts-expect-error cũng là một assertion. Nếu dòng bên dưới không còn lỗi kiểu, tsc sẽ báo “Unused ‘@ts-expect-error’ directive”.
9. Lỗi hay gặp và khi nào không nên dùng
Lưu ý: Template literal types chỉ tồn tại lúc compile. Dữ liệu từ API, form hay
process.envkhông được kiểm tra gì cả. Với dữ liệu bên ngoài, bạn vẫn cần validate ở runtime bằng regex hoặc thư viện schema.
Một số bẫy cụ thể:
${number}rộng hơn bạn nghĩ."1e3px"hay"0.5px"đều hợp lệ với`${number}px`. Nếu cần “số nguyên dương”, template literal không diễn đạt được. Hãy kiểm tra ở runtime.- Union bùng nổ. Như mục 3, ghép nhiều union lớn làm compiler chậm hẳn, thậm chí báo lỗi quá phức tạp.
- Đệ quy sâu. Kiểu đệ quy như
ExtractParamscó giới hạn độ sâu. Với chuỗi rất dài, bạn sẽ gặp “Type instantiation is excessively deep and possibly infinite”. Route thông thường không chạm tới giới hạn này, nhưng parser chuỗi tổng quát thì có thể. - Thông báo lỗi khó đọc. Khi kiểu phức tạp, lỗi có thể dài hàng chục dòng. Nếu đồng nghiệp phải đọc lỗi đó mỗi ngày, cái giá có thể lớn hơn lợi ích.
- Thiếu
& string.keyof Tcó thể chứanumber | symbol, vàCapitalizesẽ từ chối chúng. - Lạm dụng. Nếu tập giá trị nhỏ và cố định, hãy viết thẳng union literal. Viết
type Size = "sm" | "md"dễ đọc hơn bất kỳ kiểu sinh tự động nào.
Nên dùng khi có một nguồn chân lý (interface, object event, chuỗi route) và bạn muốn kiểu khác tự suy ra từ đó. Không nên dùng chỉ để thể hiện kỹ năng type-level.
10. Kết luận
Template literal types biến chuỗi thành công dân hạng nhất trong hệ thống kiểu của TypeScript. Bài viết đã đi qua các kỹ thuật chính:
- ghép chuỗi và phân phối qua union;
- đổi hoa/thường bằng
Uppercase,Capitalize…; - đổi tên và lọc key trong mapped type bằng
as; - tách chuỗi bằng
infer, kể cả theo kiểu đệ quy.
Áp dụng đúng chỗ, như route param, tên event hay getter, bạn loại bỏ được cả một lớp lỗi gõ sai tên mà trước đây chỉ phát hiện ở runtime. Hãy nhớ kiểm thử kiểu bằng expectTypeOf và @ts-expect-error. Hãy để mắt tới độ phức tạp của union và độ sâu đệ quy. Và luôn validate dữ liệu đến từ bên ngoài chương trình.