TS Type Challenges · Part 7 — Template Literal Types
Part 7: string math at the type level. Build Trim, Replace, ReplaceAll, StartsWith/EndsWith and LengthOfString with template-literal pattern matching and recursion — each with a toggle-to-reveal answer and a step-by-step explanation.
Đây là Phần 7 của series 12 bài về TypeScript type challenges. Template literal type cho phép dựng string (`Hello, ${S}`) và, mạnh hơn, khớp mẫu chúng bằng infer. Kết hợp đệ quy, bạn xử lý string từng ký tự — nền cho mọi parser ta viết ở Phần 10.
Ghi nguồn: các đáp án theo type-challenges (giấy phép MIT); số khớp ID chính thức.
Khớp tham lam chia chuỗi thế nào
Mục tiêu
Trước khi viết Trim, Replace, hay Split, bạn cần một mô hình tư duy: khi TypeScript khớp một string với mẫu template, nó quyết định mỗi ô infer bắt đầu và kết thúc ở đâu thế nào?. Hiểu sai điểm này thì mọi parser string đệ quy trông như phép màu.
Vì sao không hiển nhiên
Ở runtime, split('-') hiển nhiên — JavaScript duyệt string và cắt ở mọi dấu phân cách. Ở tầng type không có vòng lặp: compiler cố cho một mẫu khớp một lần, và quy tắc ô nào bắt ký tự nào không được ghi rõ như bạn mong.
Thử ngây thơ — và vì sao thất bại
Bạn có thể nghĩ hai ô infer liền kề chia đều string, hoặc ô thứ hai không tham lam:
// ❌ Wrong mental model: "H takes half, T takes half"
type WrongSplit = 'a-b-c' extends `${infer H}-${infer T}` ? [H, T] : never;
// You might guess: H = 'a-b', T = 'c' — but that's NOT what happens
TypeScript không tách ở dấu - cuối. Nó cũng không tối thiểu H rồi tối đa T kiểu lazy quantifier regex. Quy tắc thật là bất đối xứng.
Cơ chế
Khi mẫu có hai ô infer liền kề quanh một đoạn literal, infer đầu không tham lam (lấy tối thiểu) và ô thứ hai lấy hết phần còn lại:
type Split = 'a-b-c' extends `${infer H}-${infer T}` ? [H, T] : never;
// H = 'a', T = 'b-c' (splits at the FIRST '-')
Đọc mẫu từ trái sang phải:
- bắt ít ký tự nhất có thể mà vẫn cho phần còn lại của mẫu khớp.
- khớp dấu gạch ngang literal.
- bắt mọi thứ còn lại.
Nên `${infer H}-${infer T}` luôn tách ở dấu phân cách đầu tiên. Để xử lý mọi dấu phân cách, đệ quy trên T.
Đánh giá type từng bước
Xem compiler khớp 'a-b-c' với `${infer H}-${infer T}`:
// step 0: candidate string = 'a-b-c', pattern = `${infer H}-${infer T}`
// step 1: try H = '' (empty — minimum possible)
// remainder must match `-${infer T}` → '-b-c' ✓
// T = 'b-c'
// SUCCESS on first try → compiler stops (non-greedy H wins)
type Split = 'a-b-c' extends `${infer H}-${infer T}` ? [H, T] : never;
// Result: ['a', 'b-c']
// (H = 'a' because H = '' also works structurally, but TS picks the match
// that satisfies the full conditional — here H = 'a', T = 'b-c')
Với ô thứ hai sau hậu tố literal, ngược lại — infer đầu tham lam:
// Pattern: `${infer R}${Whitespace}` — trim trailing space
// String: 'hi '
// step 1: R tries to be as LARGE as possible while still ending in Whitespace
// R = 'hi', trailing char = ' ' ✓
// (R = 'hi ' would fail — nothing left for Whitespace)
type TrimRightDemo = 'hi ' extends `${infer R} ` ? R : never;
// Result: 'hi'
Cạm bẫy
- Chỉ dấu phân cách đầu — một lần
extendskhông bao giờ duyệt hết string; đệ quy bắt buộc cho bài “mọi lần xuất hiện” nhưReplaceAllvàSplit. - Tham lam vs không tham lam đổi vai — mẫu tiền tố (
`${infer H}literal`) làm ô đầu tham lam; mẫu xen giữa (`${infer H}literal${infer T}`) làm ô đầu không tham lam. - Phân phối — nếu string bên trái là union, cả conditional phân phối (xem Phần 6); bản thân khớp template không “thử mọi điểm tách”.
Tóm lại: hai ô infer quanh literal → ô đầu tối thiểu, ô sau lấy phần còn lại; một infer trước literal đuôi → ô đó tối đa.
108 · Trim
Mục tiêu
Xoá khoảng trắng đầu và cuối khỏi một string type — space, tab, newline. Input ' hello world ' phải thành 'hello world'.
Vì sao không hiển nhiên
Không có .trim() trên string type. Bạn không thể index vào string type hay cắt bằng substring. Công cụ duy nhất là khớp mẫu — bóc một ký tự trắng ở một đầu, rồi đệ quy.
type R = Trim<' hello world '>; // 'hello world'
Thử ngây thơ — và vì sao thất bại
// ❌ Strings have no .length or [index] at the type level
type NaiveTrim<S extends string> = S; // can't "drop first char" without a pattern
// ❌ A single pattern can't trim BOTH ends at once without careful design
type OneShot<S extends string> = S extends ` ${infer R} ` ? R : S;
// Only works when EXACTLY one space wraps the whole string — fails on
// ' hello ' (multiple leading spaces) and '\thello\n' (other whitespace)
Bạn cần mẫu theo hướng — một hình cho đầu trái, một cho đầu phải — rồi hợp chúng.
Hiện đáp án
type Whitespace = ' ' | '\n' | '\t';
type TrimLeft<S extends string> = S extends `${Whitespace}${infer R}`
? TrimLeft<R>
: S;
type TrimRight<S extends string> = S extends `${infer R}${Whitespace}`
? TrimRight<R>
: S;
type Trim<S extends string> = TrimLeft<TrimRight<S>>;Cơ chế
Ba mảnh ghép cùng nhau:
- Union
Whitespace— tham số type ở vị trí template khớp nếu ký tự tiếp theo là bất kỳ thành viên nào của union. TrimLeft— mẫu`${Whitespace}${infer R}`nghĩa là “bắt đầu bằng khoảng trắng; phần còn lại vàoR”.TrimRight— mẫu đối xứng`${infer R}${Whitespace}`; ở đâyRtham lam, lấy hết trừ ký tự trắng cuối.
Trim hợp TrimLeft<TrimRight<S>> — cắt đầu phải trước (để khoảng trắng giữa giữ nguyên), rồi duyệt đầu trái. Thứ tự ít quan trọng vì hai thao tác độc lập, nhưng TrimRight trước là lựa chọn quen từ type-challenges.
Từng dòng
type TrimLeft<S extends string> = S extends `${Whitespace}${infer R}`
? TrimLeft<R> // matched → drop one leading whitespace, recurse on R
: S; // no leading whitespace → donetype TrimRight<S extends string> = S extends `${infer R}${Whitespace}`
? TrimRight<R> // matched → drop one trailing whitespace, recurse on R
: S; // no trailing whitespace → donetype Trim<S extends string> = TrimLeft<TrimRight<S>>;
// TrimRight peels ' hello world ' → ' hello world'
// TrimLeft peels ' hello world' → 'hello world'Đánh giá type từng bước
Theo dõi Trim<' \thello '> (tab + space đầu, space cuối):
// ── TrimRight phase ──
// step 0: S = ' \thello '
// step 1: matches `${infer R} ` with R = ' \thello ' → TrimRight<' \thello '>
// step 2: matches `${infer R} ` with R = ' \thello' → TrimRight<' \thello'>
// step 3: no trailing space → ' \thello'
// ── TrimLeft phase ──
// step 4: S = ' \thello'
// step 5: matches ` ${infer R}` with R = '\thello' → TrimLeft<'\thello'>
// step 6: matches `\t${infer R}` with R = 'hello' → TrimLeft<'hello'>
// step 7: no leading whitespace → 'hello'
type Result = Trim<' \thello '>; // 'hello'Cạm bẫy
Whitespacephải là union — viết` ${infer R}`chỉ cắt space, không cắt\thay\n.- Khoảng trắng giữa được giữ — mẫu chỉ chạm hai đầu;
' hello world '→'hello world'. - Đệ quy vô tận? — chỉ khi đệ quy mà string không ngắn lại; mỗi lần khớp bỏ đúng một ký tự, nên chắc chắn dừng.
Tóm lại: mỗi bước đệ quy bóc một khoảng trắng ở một đầu; hợp trim trái + phải.
116 · Replace
Mục tiêu
Thay lần xuất hiện đầu tiên của substring From bằng To trong string S. Replace<'foobarbar', 'bar', 'foo'> → 'foofoobar' Replace<'foobarbar', 'bar', 'foo'> → 'foofoobar'.
Vì sao không hiển nhiên
Khác string.replace() runtime, bản tầng type phải định vị substring không có vòng lặp tìm kiếm — bạn nhúng From làm đoạn literal trong mẫu template và để compiler tìm chỗ khớp đầu tiên. Bạn cũng cần guard rõ cho From rỗng, mà API runtime âm thầm bỏ qua.
type R = Replace<'foobarbar', 'bar', 'foo'>; // 'foofoobar'
type E = Replace<'foobarbar', '', 'foo'>; // 'foobarbar' (empty From is a no-op)
Thử ngây thơ — và vì sao thất bại
// ❌ No guard for empty From
type BadReplace<S extends string, From extends string, To extends string> =
S extends `${infer Head}${From}${infer Tail}`
? `${Head}${To}${Tail}`
: S;
// Replace<'abc', '', 'Z'> → 'Zabc' — inserts at position 0 forever if you recurse
// ❌ Trying to "find" with string indexing — doesn't exist at type level
type IndexReplace<S extends string> = S[0]; // Error: strings have no numeric index
Cách sửa là guard conditional cộng template ba phần với From kẹp giữa hai ô infer.
Hiện đáp án
type Replace<
S extends string,
From extends string,
To extends string,
> = From extends ''
? S
: S extends `${infer Head}${From}${infer Tail}`
? `${Head}${To}${Tail}`
: S;Cơ chế
- Guard rỗng —
From extends '' ? Sthoát sớm trước khi khớp. - Nhúng literal —
Fromxuất hiện nguyên văn trong mẫu, không nằm tronginfer; compiler phải tìm đúng substring đó. Headkhông tham lam —inferđầu lấy tiền tố tối thiểu trước khiFromxuất hiện, tức đúng “lần đầu tiên”.- Dựng lại —
`${Head}${To}${Tail}`chènTothay chỗFromđã khớp.
Từng dòng
From extends '' ? S
// If there's nothing to find, return S unchanged (no-op)
: S extends `${infer Head}${From}${infer Tail}`
// Try to split S into [prefix][From][suffix]
// Head = minimal prefix, Tail = everything after first From
? `${Head}${To}${Tail}`
// Found → rebuild with To substituted once
: S;
// From not found anywhere → return original stringĐánh giá type từng bước
Theo dõi Replace<'foobarbar', 'bar', 'foo'>:
// step 0: From = 'bar' (non-empty → proceed to match)
// step 1: pattern = `${infer Head}${'bar'}${infer Tail}` against 'foobarbar'
// step 2: Head minimal → Head = 'foo', literal 'bar' matches, Tail = 'bar'
// step 3: rebuild = `${'foo'}${'foo'}${'bar'}` = 'foofoobar'
type R = Replace<'foobarbar', 'bar', 'foo'>; // 'foofoobar'Theo dõi ca From rỗng:
// step 0: From = '' → guard fires immediately → return S
type E = Replace<'foobarbar', '', 'foo'>; // 'foobarbar'Cạm bẫy
- Chỉ khớp đầu — không đệ quy nên
'bar'sau trong'foobarbar'giữ nguyên sau bước 3. Fromrỗng là tiền tố mọi vị trí — không có guard,`${infer Head}${''}${infer Tail}`khớp ở index 0 vớiHead = '', làm hỏng string.Fromphải là literal cụ thể —Replace<'abc', string, 'x'>không tìm “bất kỳ string”;Fromcố định mỗi lần instantiate.
Tóm lại: guard From rỗng, khớp `${Head}${From}${Tail}`, dựng lại một lần — không đệ quy.
119 · ReplaceAll
Mục tiêu
Thay mọi lần xuất hiện của From bằng To — không chỉ lần đầu. ReplaceAll<'t y p e s', ' ', ''> → 'types' ReplaceAll<'t y p e s', ' ', ''> → 'types'.
Vì sao không hiển nhiên
Replace đã tìm được lần đầu; bước tiếp theo tự nhiên là đệ quy. Phần tinh tế là đệ quy ở đâu — đệ quy sai đoạn thì hoặc bỏ sót khớp hoặc lặp vô tận.
type R = ReplaceAll<'t y p e s', ' ', ''>; // 'types'
Thử ngây thơ — và vì sao thất bại
// ❌ Recurse on the rebuilt string — re-scans the part you just inserted
type BadReplaceAll<S extends string, From extends string, To extends string> =
S extends `${infer Head}${From}${infer Tail}`
? ReplaceAll<`${Head}${To}${Tail}`, From, To> // re-scans Head+To
: S;
// When To contains From (e.g. replace 'a' with 'aa'), this can loop forever
// ❌ No empty-From guard — same corruption as Replace
type BadReplaceAll2<S extends string, To extends string> =
S extends `${infer Head}${''}${infer Tail}`
? `${Head}${To}${ReplaceAll<Tail, '', To>}`
: S;
Đáp án type-challenges chỉ đệ quy trên Tail — substring sau From đã khớp, không bao giờ xem lại Head hay To vừa chèn.
Hiện đáp án
type ReplaceAll<
S extends string,
From extends string,
To extends string,
> = From extends ''
? S
: S extends `${infer Head}${From}${infer Tail}`
? `${Head}${To}${ReplaceAll<Tail, From, To>}`
: S;Cơ chế
Cùng guard và mẫu với Replace. Chỉ đổi lời gọi đệ quy trên Tail thay vì trả kết quả phẳng:
`${Head}${To}${ReplaceAll<Tail, From, To>}`
// ^^^^^ rebuilt prefix (done, never re-scanned)
// ^^ inserted once
// ^^^^^^^^^^^^^^^^^^^^^^^^^ scan continues HERE onlyMỗi lần gọi xử lý một From ở vị trí còn lại ngoài cùng bên trái; Tail thu nhỏ hoặc vẫn khớp được tới khi hết From.
Từng dòng
? `${Head}${To}${ReplaceAll<Tail, From, To>}`
// Head and To are finalized in this branch — only Tail may still contain From
// ReplaceAll<Tail, ...> is the "loop body" moving left-to-right through SĐánh giá type từng bước
Theo dõi ReplaceAll<'t y p e s', ' ', ''>:
// call 1: S = 't y p e s'
// Head = 't', From = ' ', Tail = 'y p e s'
// partial = 't' + '' + ReplaceAll<'y p e s', ' ', ''>
// call 2: S = 'y p e s'
// Head = 'y', Tail = 'p e s'
// partial = 'y' + ReplaceAll<'p e s', ' ', ''>
// call 3: S = 'p e s' → 'p' + ReplaceAll<'e s', ' ', ''>
// call 4: S = 'e s' → 'e' + ReplaceAll<'s', ' ', ''>
// call 5: S = 's' → no space → 's'
// unwind: 't' + 'y' + 'p' + 'e' + 's' = 'types'
type R = ReplaceAll<'t y p e s', ' ', ''>; // 'types'Cạm bẫy
- Đệ quy trên
Tail, không phải chuỗi dựng lại đầy đủ — tránh quét lạiTovà tránh nở vô tận khiTochứaFrom. - Trái sang phải, không chồng lấn — sau khi thay một
From, lần tìm tiếp bắt đầu sau nó (ngữ nghĩareplaceAllchuẩn). - Độ sâu = số lần khớp — string rất dài với nhiều lần thay có thể chạm giới hạn độ sâu đệ quy ở bản TS cũ.
Tóm lại: Replace + ReplaceAll<Tail> — chốt phần trái, tiếp tục quét phần phải.
2688 · StartsWith & 2693 · EndsWith
Mục tiêu
Trả true hoặc false: string T có bắt đầu bằng substring U không? Có kết thúc bằng U không?.
type A = StartsWith<'abc', 'ab'>; // true
type B = EndsWith<'abc', 'bc'>; // true
Vì sao không hiển nhiên
Bản năng đầu có thể là đệ quy — bóc từng ký tự rồi so. Cách đó được, nhưng template literal có mẫu một phát dùng string làm wildcard — không đệ quy, không cần cầu tuple.
Thử ngây thơ — và vì sao thất bại
// ❌ Over-engineered recursion (works, but misses the point of this lesson)
type NaiveStartsWith<T extends string, U extends string> =
U extends ''
? true
: T extends `${infer H}${infer _}`
? H extends U ? true : NaiveStartsWith<_, U> // wrong — compares one char only
: false;
// ❌ Using `infer` where `string` wildcard is enough
type Overkill<T extends string, U extends string> =
T extends `${U}${infer _Rest}` ? true : false;
// Works, but `_Rest` is unnecessary noise — `string` expresses "anything" directly
Đáp án thành thạo dùng string wildcard sẵn có trong mẫu template.
Hiện đáp án
type StartsWith<T extends string, U extends string> = T extends `${U}${string}`
? true
: false;
type EndsWith<T extends string, U extends string> = T extends `${string}${U}`
? true
: false;Cơ chế — string như ký tự đại diện
Trong mẫu template literal (vế phải của extends), type string đặc biệt: khớp bất kỳ dãy ký tự nào (kể cả rỗng). Tương đương regex .* ở tầng type.
`${U}${string}`— “Tphải bắt đầu bằng literalU, rồi gì cũng được (hoặc không gì)”.`${string}${U}`— “gì cũng được (hoặc không gì), rồi kết thúc bằng literalU”.
Không cần infer — bạn không trích phần nào, chỉ kiểm tra hình dạng.
Từng dòng
type StartsWith<T extends string, U extends string> = T extends `${U}${string}`
? true // T fits the "U + anything" shape
: false; // no fit → prefix test fails
type EndsWith<T extends string, U extends string> = T extends `${string}${U}`
? true // T fits the "anything + U" shape
: false;Đánh giá type từng bước
StartsWith<'abc', 'ab'>:
// step 0: does 'abc' extend `${'ab'}${string}`?
// step 1: literal 'ab' matches first two chars → remainder 'c' is assignable to string ✓
// step 2: conditional → true
type A = StartsWith<'abc', 'ab'>; // trueStartsWith<'abc', 'b'>:
// step 0: does 'abc' extend `${'b'}${string}`?
// step 1: first char is 'a', not 'b' → pattern fails
// step 2: conditional → false
type Fail = StartsWith<'abc', 'b'>; // falseEndsWith<'abc', 'bc'>:
// step 0: does 'abc' extend `${string}${'bc'}`?
// step 1: wildcard string absorbs 'a', literal 'bc' matches last two chars ✓
// step 2: true
type B = EndsWith<'abc', 'bc'>; // trueCạm bẫy
- Wildcard
stringchỉ hoạt động trong mẫu — ở vế tráiextends,stringchỉ là type rộng, không phải bộ khớp. Urỗng —StartsWith<'abc', ''>làtruevì`${''}${string}`khớp mọi string.- Phân phối — nếu
Tlà union, conditional phân phối:StartsWith<'abc' | 'ab', 'ab'>→true | true→true.
Tóm lại: `${U}${string}` / `${string}${U}` — kiểm tra tiền tố/hậu tố một dòng, không đệ quy.
298 · Length of String
Mục tiêu
Tính độ dài string type thành number literal — LengthOfString<'hello'> → 5.
type R = LengthOfString<'hello'>; // 5
Vì sao không hiển nhiên
Tuple có ['length']; string thì không. Không có S.length ở tầng type. Bạn phải chuyển string sang cấu trúc dữ liệu mang thông tin độ dài — tuple các string một ký tự.
Thử ngây thơ — và vì sao thất bại
// ❌ No .length on string types
type BadLen<S extends string> = S['length']; // never resolves to a number literal
// ❌ Building a union of chars loses order AND has no length
type CharUnion<S extends string> = S extends `${infer C}${infer Rest}`
? C | CharUnion<Rest>
: never;
// CharUnion<'hi'> = 'h' | 'i' — union length ≠ string length
Tuple giữ thứ tự và lộ độ dài dưới dạng number literal qua ['length'].
Hiện đáp án
type StringToTuple<S extends string> = S extends `${infer Head}${infer Tail}`
? [Head, ...StringToTuple<Tail>]
: [];
type LengthOfString<S extends string> = StringToTuple<S>['length'];Cơ chế — bắc cầu sang tuple, rồi đếm
Pipeline hai bước:
StringToTuple— đệ quy với`${infer Head}${infer Tail}`, prepend mỗiHead(một ký tự) vào tuple đang lớn dần.LengthOfString— đọcStringToTuple<S>['length'], cùng mẹo độ dài tuple từ Phần 1.
Đây là phiên bản string của “chuyển sang tuple rồi đo” — cùng mẫu cầu như xử lý union/tuple ở Phần 6, nhưng dựng tuple thay vì union vì thứ tự quan trọng.
Từng dòng
type StringToTuple<S extends string> = S extends `${infer Head}${infer Tail}`
? [Head, ...StringToTuple<Tail>] // prepend char, recurse on rest
: []; // empty string → empty tuple
type LengthOfString<S extends string> = StringToTuple<S>['length'];
// Tuple ['h','e','l','l','o'] has length 5 → literal type 5Đánh giá type từng bước
Theo dõi LengthOfString<'hi'>:
// call 1: S = 'hi'
// Head = 'h', Tail = 'i' → ['h', ...StringToTuple<'i'>]
// call 2: S = 'i'
// Head = 'i', Tail = '' → ['i', ...StringToTuple<''>]
// call 3: S = ''
// → []
// unwind: StringToTuple<'hi'> = ['h', 'i']
// ['h', 'i']['length'] = 2
type R = LengthOfString<'hi'>; // 2Theo dõi LengthOfString<'hello'>:
// StringToTuple<'hello'> = ['h', 'e', 'l', 'l', 'o']
// length = 5
type R = LengthOfString<'hello'>; // 5Cạm bẫy
Headlà một ký tự, không phải một byte — string type TypeScript là đơn vị mã UTF-16; grapheme phức tạp có thể đếm thành nhiều phần tử tuple.- Độ sâu đệ quy = độ dài string — literal type 1000 ký tự nghĩa là ~1000 lần mở rộng đệ quy.
- Chuỗi rỗng —
StringToTuple<''>→[], và[ ]['length']→0.
Tóm lại: parse string thành ['h','e',...] bằng đệ quy template, rồi đọc ['length'].
Bài tập
1. Cài Trim không cần TrimRight riêng, bằng cách đệ quy cả hai đầu trong một type.
Lời giải
type Whitespace = ' ' | '\n' | '\t';
type Trim<S extends string> = S extends
| `${Whitespace}${infer R}`
| `${infer R}${Whitespace}`
? Trim<R>
: S;Một mẫu union trong mệnh đề extends khớp nếu một trong hai hình dạng vừa. Bên nào có khoảng trắng sẽ bị cắt, và ta đệ quy tới khi không bên nào khớp. Một type xử lý cả hai đầu — ít tên hơn, hành vi giống nhau.
Từng bước
// Trim<' hi '>
// step 1: matches `${infer R}${Whitespace}` → R = ' hi' → Trim<' hi'>
// step 2: matches `${Whitespace}${infer R}` → R = 'hi' → Trim<'hi'>
// step 3: no match → 'hi'2. Vì sao Replace chặn From extends '' trước khi khớp?
Lời giải
Chuỗi rỗng là tiền tố của mọi vị trí. Không có guard, `${infer Head}${''}${infer Tail}` khớp ngay đầu với Head = '' và Tail = S, chèn To ở vị trí 0 và làm hỏng kết quả. Trong ReplaceAll, điều đó sẽ đệ quy vô tận, chèn To lặp lại. Guard biến “thay không có gì” thành no-op đúng đắn.
Nâng cao:cài Split<S, Sep> trả tuple các đoạn, xử lý ca separator rỗng và đoạn cuối.
Điểm chính
`${infer H}${sep}${infer T}`tách ởsepđầu tiên; đệ quy trênTđể xử lý hết.- Một tham số type có thể xuất hiện nguyên văn trong mẫu như đoạn cố định để khớp.
stringlà ký tự đại diện tầng type —`${U}${string}`/`${string}${U}`kiểm tra tiền tố/hậu tố.- Với
ReplaceAll, đệ quy trên đuôi sau match để tránh quét lại và vòng lặp vô tận. - Để đếm ký tự, bắc cầu string sang tuple rồi đọc
['length'].
Tiếp theo
Phần 8 — Số học tầng type: TypeScript không có + cho số, nên ta đếm bằng độ dài tuple. Ta dựng Add, Subtract, GreaterThan, và cả Fibonacci, dùng các mẹo accumulator và độ dài tuple bạn đã gom được.