← TypeScript Thực Chiến

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à onemit 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

Thử 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! 🎉

Hành trình: vì sao TypeScript & type erasure → kiểu cơ bản & suy luận → interface & structural typing → class → union & narrowing → generics → conditional types → mapped & template literal → utility types → tsconfig & .d.ts → di chuyển JS sang TS → và giờ là ba dự án thật. Bước tiếp theo: mang TypeScript vào một framework (Svelte, React, Vue) hoặc một backend gõ kiểu, nơi mọi thứ mèo con vừa học sẽ dùng mỗi ngày.

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á.

Ba dấu hiệu: strict bật (mặc định ở TS6), không còn any nào (trừ any đã đánh dấu TODO), và dùng SAI thì tsc báo lỗi. Thử cố tình gọi sai - nếu compiler bắt được, kiểu của bạn đang làm việc.

Mang TypeScript vào một framework: Svelte, React hay Vue đều có hỗ trợ TS rất tốt; hoặc một backend gõ kiểu với Node. Khi đó các kỹ năng ở đây - generics, union, utility type - sẽ dùng mỗi ngày.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Trong Stack<T>, vì sao pop() trả về T | undefined chứ không phải T?

Bài tập về nhà

  1. 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. 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. 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. 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. 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. 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.