Bài 12 · Nâng cao · 32 phút
Dự án cuối khoá
Biên soạn bởi Nguyễn Anh Tuấn
Ba dự án nhỏ ghép mọi kỹ năng: (1) một thư viện tiện ích generic an toàn kiểu; (2) một helper type tự dựng (vd typed event emitter hoặc tự viết lại một utility type); (3) di chuyển một module JavaScript sang TypeScript strict. Tổng hợp toàn khoá.
Bài cuối không dạy gì mới - mèo con sẽ tự làm ba dự án nhỏ, mỗi cái chạy được và an toàn kiểu hoàn toàn, để ghép lại mọi thứ đã học. Dưới đây là đề bài kèm một lời giải mẫu cho mỗi dự án (mèo con nên tự viết trước, rồi mới so).
- ▸Dự án 1 - Thư viện generic: ôn generics (Bài 6) với một class Stack<T>.
- ▸Dự án 2 - Typed event emitter: ghép generics + mapped type + keyof (Bài 8).
- ▸Dự án 3 - Migrate: đưa một module JavaScript sang TypeScript strict (Bài 10-11).
Dựng một Stack<T> (ngăn xếp) dùng được cho mọi kiểu phần tử mà
không mất an toàn - đúng tinh thần generics:
stack.ts
class Stack<T> {
private items: T[] = [];
push(item: T): void { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
peek(): T | undefined { return this.items[this.items.length - 1]; }
get size(): number { return this.items.length; }
}
const s = new Stack<number>();
s.push(1);
s.push(2);
console.log(s.pop(), s.peek(), s.size);
// s.push("ba"); // Loi: "ba" la string, Stack<number> chi nhan number Kết quả khi chạy
2 1 1
- ▸Tham số kiểu T nối đầu vào (push) với đầu ra (pop) - không mất kiểu.
- ▸Stack<number> chỉ nhận số; push một chuỗi vào bị tsc chặn ngay.
- ▸pop/peek trả về T | undefined - buộc người dùng xử lý trường hợp rỗng.
Tham vọng hơn: một bộ phát sự kiện mà on và emit tự biết payload của từng sự kiện, dựa trên một mapped type trên bản đồ sự kiện. Gõ sai payload là tsc bắt được:
emitter.ts
type EventMap = {
click: { x: number };
msg: { text: string };
};
class Emitter<M> {
private handlers: { [K in keyof M]?: ((p: M[K]) => void)[] } = {};
on<K extends keyof M>(event: K, fn: (p: M[K]) => void): void {
(this.handlers[event] ??= []).push(fn);
}
emit<K extends keyof M>(event: K, payload: M[K]): void {
this.handlers[event]?.forEach((fn) => fn(payload));
}
}
const e = new Emitter<EventMap>();
e.on("click", (p) => console.log("click tai x =", p.x)); // p: { x: number }
e.emit("click", { x: 10 });
// e.emit("click", { text: "sai" }); // Loi: payload sai kieu Kết quả khi chạy
click tai x = 10
tsc bắt payload sai
e.emit("click", { text: "sai" }) là tsc báo: Object literal may only specify known properties, and 'text' does not exist in type
'{ x: number; }' (TS2353). Toàn bộ sự an toàn đó đến từ generics cộng mapped type - và
biến mất sạch khi chạy (type erasure).Cuối cùng, đưa một mẩu JavaScript chưa kiểu về TypeScript strict, đúng cách di chuyển ở bài trước: thêm kiểu, xử lý trường hợp rỗng mà strict nhắc, bỏ mọi any:
trung-binh.ts (sau khi migrate, strict)
// Truoc: function tinhTrungBinh(ds) { return ds.reduce(...) / ds.length; }
// (mang rong -> chia cho 0 -> NaN, va ds ngam la any)
function tinhTrungBinh(ds: number[]): number {
if (ds.length === 0) return 0; // strictNullChecks/logic nhac xu ly rong
return ds.reduce((a, b) => a + b, 0) / ds.length;
}
console.log(tinhTrungBinh([8, 9, 10])); Kết quả khi chạy
9
- ▸Thêm kiểu tham số (number[]) và kiểu trả về (number) - hết any ngầm.
- ▸Xử lý trường hợp rỗng để tránh NaN - strict đẩy bạn nghĩ tới ca biên.
- ▸Module nhỏ, kiểu chặt, gọi sai bị bắt: đúng đích của cả khoá.
Ba dự án nhỏ này gói trọn hành trình: từ kiểu cơ bản, qua hệ thống kiểu, lên tới lập trình ở tầng kiểu, rồi về với cấu hình và migration đời thực. Mèo con giờ không chỉ DÙNG được TypeScript mà còn HIỂU nó vận hành ra sao bên dưới.
Chúc mừng - mèo con đã đi trọn 12 bài! 🎉
Câu hỏi thường gặp
Bắt đầu từ Dự án 1 (Stack generic) - nhẹ nhất, ôn lại generics. Rồi Dự án 2 (typed event emitter) gộp generics với mapped type. Cuối cùng Dự án 3 (migrate) là phần thực chiến nhất. Đi theo thứ tự đó để độ khó tăng dần.
Không. Mỗi dự án chỉ vài chục dòng là đủ - mục tiêu là CHẶT về kiểu, không phải nhiều tính năng. Một Stack 20 dòng nhưng an toàn kiểu hoàn toàn đáng giá hơn một app to mà đầy any.
Nên, vì mỗi dự án luyện một nhóm kỹ năng khác nhau: Dự án 1 cho generics, Dự án 2 cho type-level (mapped/keyof), Dự án 3 cho tooling và migration. Làm cả ba là ôn trọn khoá.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Trong Stack<T>, vì sao pop() trả về T | undefined chứ không phải T?
- 1
Dự án 1: Stack<T>
Hoàn thành class Stack<T> với push/pop/peek/size. Tạo một Stack<number> và một Stack<string>, thử push sai kiểu xem tsc chặn.
✅ Hoàn thành khi: Stack<number> chỉ nhận số, Stack<string> chỉ nhận chuỗi; pop trả về T | undefined; push sai kiểu bị tsc báo lỗi.
- 2
Mở rộng Dự án 1
Thêm một hàm tiện ích generic vào thư viện, vd last<T>(arr: T[]): T | undefined hoặc map của riêng mèo con.
✅ Hoàn thành khi: Hàm giữ đúng kiểu phần tử (last của number[] ra number | undefined), biên dịch sạch dưới strict.
- 3
Dự án 2: typed event emitter
Hoàn thành Emitter<M> với on/emit gõ kiểu theo một EventMap. Đăng ký một handler và emit đúng payload.
✅ Hoàn thành khi: Handler nhận payload đúng kiểu của event; emit sai payload bị tsc báo lỗi (TS2353).
- 4
Mở rộng Dự án 2
Thêm một sự kiện mới vào EventMap (vd error với payload riêng) và một handler cho nó.
✅ Hoàn thành khi: Sự kiện mới hoạt động với payload đúng kiểu; on/emit cho sự kiện cũ không bị ảnh hưởng.
- 5
Dự án 3: migrate một module
Lấy một module JavaScript nhỏ của riêng mèo con (hoặc tự viết một cái). Di chuyển sang TypeScript strict: thêm kiểu, xử lý null, bỏ any.
✅ Hoàn thành khi: Module là .ts biên dịch sạch dưới strict, không còn any chưa đánh dấu; gọi sai bị bắt lỗi.
- 6
Khoe thành quả
Gộp ba dự án vào một repo nhỏ với tsconfig strict. Viết một README ngắn nói mỗi dự án dùng kỹ năng nào của khoá.
✅ Hoàn thành khi: Một repo biên dịch sạch dưới strict, README liệt kê được kỹ năng (generics, mapped type, migration) cho từng dự án.