private trong TypeScript nghe như một cái khoá, nhưng nó chỉ là lời hứa lúc compile time. Build ra JavaScript, field private vẫn nằm trần trong object, và obj["balance"] vẫn đọc được ở runtime. Vậy khi nào private là đủ, khi nào cần #field runtime thật, abstract khác implements chỗ nào, và bao giờ nên bỏ kế thừa để dùng composition? Bài này chỉ nói phần TypeScript thêm vào class — còn cú pháp class, prototype, extends/super đã có ở bài OOP và Prototypes trong JavaScript hiện đại.
1. private, protected, public chỉ sống ở compile time
TypeScript cho bạn ba access modifier — public (mặc định), private, protected. Cả ba chỉ tồn tại lúc compile time: chúng là chỉ dẫn cho compiler và cho đồng đội, không phải rào chắn ở runtime. Compiler báo lỗi ngay khi bạn chạm vào private từ ngoài class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class BankAccount {
constructor(private balance: number) {}
}
const account = new BankAccount(1000);
account.balance;
// ❌ error TS2341: Property 'balance' is private and only
// accessible within class 'BankAccount'.
account["balance"];
// ✅ compile được — bracket access bằng string literal KHÔNG bị chặn (một cửa sau lúc compile)
(account as any).balance;
// ✅ ép sang any cũng làm compiler thôi kiểm tra — thêm một cửa sau nữaNhưng rào chắn đó chỉ có lúc compile. Build ra JavaScript, từ khoá private bị xoá sạch và balance trở thành một property bình thường:
1
2
3
4
5
6
7
8
9
10
// Đây chính là JavaScript mà class ở trên compile ra — private đã biến mất
class BankAccount {
constructor(balance) {
this.balance = balance;
}
}
const account = new BankAccount(1000);
console.log(account["balance"]); // 1000 — đọc được, không ai chặn
console.log(JSON.stringify(account)); // {"balance":1000} — lộ cả khi serializeChạy thử: in ra 1000 rồi {"balance":1000}. Nên hiểu đúng bản chất: private là kỷ luật code — nó giúp đội ngũ không lỡ tay dùng API nội bộ và giúp editor gợi ý gọn hơn. Nó không phải cơ chế bảo mật, nên đừng bao giờ trông vào private để giấu token, key hay dữ liệu nhạy cảm khỏi người dùng cuối.
1.1. protected mở cửa cho class con
protected giống private nhưng nới thêm một bậc: class con truy cập được, code ngoài thì không.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Account {
protected balance = 0;
}
class SavingsAccount extends Account {
addInterest() {
this.balance *= 1.05; // ✅ class con đọc/ghi được protected
}
}
const acc = new Account();
acc.balance;
// ❌ error TS2445: Property 'balance' is protected and only
// accessible within class 'Account' and its subclasses.Dùng protected khi một field/method là chi tiết nội bộ nhưng class con cần dựa vào — như balance mà chỉ nghiệp vụ kế thừa (SavingsAccount) mới được đụng tới.
2. #field: đóng gói thật ở runtime
Khi bạn cần một field không ai chạm được kể cả ở runtime, dùng #private field của ECMAScript. Đây là đóng gói thật, không phải lời hứa lúc compile:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class BankAccount {
#balance;
constructor(balance) {
this.#balance = balance;
}
getBalance() {
return this.#balance; // chỉ code bên trong class đọc được
}
}
const account = new BankAccount(1000);
console.log(account.getBalance()); // 1000 — qua method thì được
console.log(Object.keys(account)); // [] — field ẩn hoàn toàn
console.log(JSON.stringify(account)); // {} — không lộ ra khi serializeChạy thử: in ra 1000, rồi [], rồi {}. #balance không nằm trong Object.keys, không lộ khi JSON.stringify, và truy cập từ ngoài class thậm chí không parse được — nó là lỗi cú pháp, không phải lỗi runtime mà bạn try/catch được:
1
2
3
4
5
6
7
8
9
10
class BankAccount {
#balance;
constructor(balance) {
this.#balance = balance;
}
}
const account = new BankAccount(1000);
account.#balance;
// ❌ SyntaxError: Private field '#balance' must be declared in an enclosing class2.1. private hay #field?
Hai cách đóng gói, khác nhau ở chỗ căn bản là “khi nào” việc kiểm tra xảy ra:
private (TypeScript) | #field (ECMAScript) | |
|---|---|---|
| Kiểm tra lúc nào | compile time | runtime thật |
| Sau khi build ra JS | biến mất, đọc được | vẫn ẩn |
| Truy cập sai từ ngoài | lỗi compile (TS2341) | SyntaxError |
Lộ khi JSON.stringify | có | không |
Khuyến nghị: dùng # khi cần đóng gói thật — dữ liệu không muốn ai chạm dù cố ý, hoặc code bạn ship thành thư viện cho người khác dùng. Dùng private khi chỉ cần kỷ luật nội bộ trong team và muốn cú pháp quen mắt. Cơ chế #field hoạt động thế nào ở tầng JavaScript thuần thì bài OOP và Prototypes đã mổ xẻ; ở đây ta chỉ đặt nó cạnh private để chọn.
3. readonly và parameter properties: constructor gọn hơn
readonly cho phép gán field đúng một lần (lúc khai báo hoặc trong constructor), sau đó khoá lại:
1
2
3
4
5
6
7
8
class User {
constructor(public readonly id: string) {}
}
const u = new User("u1");
u.id; // ✅ đọc thoải mái
u.id = "u2";
// ❌ error TS2540: Cannot assign to 'id' because it is a read-only property.Chú ý dòng constructor(public readonly id: string) ở trên: đó là parameter property — viết access modifier ngay trước tham số constructor, TypeScript tự khai báo field và gán this.id = id giùm bạn. Nó cắt bỏ đoạn khai báo field rồi gán tay lặp đi lặp lại, và đọc rất đúng ý đồ dependency injection:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type User = { id: string; name: string };
class InMemoryUserRepo {
private users = new Map<string, User>();
save(user: User) {
this.users.set(user.id, user);
}
findById(id: string): User | undefined {
return this.users.get(id);
}
}
class UserService {
// parameter property: khai báo + gán field repo chỉ trong một dòng
constructor(private readonly repo: InMemoryUserRepo) {}
register(id: string, name: string): User | undefined {
this.repo.save({ id, name });
return this.repo.findById(id);
}
}
const service = new UserService(new InMemoryUserRepo());
console.log(service.register("u1", "An"));Chạy thử: in ra { id: 'u1', name: 'An' }. UserService nhận repo qua constructor thay vì tự tạo — nhờ vậy lúc test bạn tráo được một repo giả vào dễ dàng. Đây cũng chính là composition mà mục 7 sẽ nói kỹ.
4. abstract class: khung chung, bắt con điền chi tiết
abstract class không tạo instance trực tiếp được. Nó định nghĩa phần dùng chung (state, logic) cộng với vài abstract method mà class con bắt buộc phải implement:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
abstract class PaymentMethod {
abstract pay(amount: number): string; // con phải tự viết
// logic chung — mọi phương thức thanh toán đều in hoá đơn giống nhau
receipt(amount: number): string {
return `${this.pay(amount)} — tổng ${amount}đ`;
}
}
class MomoPayment extends PaymentMethod {
pay(amount: number) {
return `Thanh toán ${amount}đ qua Momo`;
}
}
console.log(new MomoPayment().receipt(50000));Chạy thử: in ra Thanh toán 50000đ qua Momo — tổng 50000đ. receipt() là logic chung viết một lần ở class cha, còn pay() để mỗi phương thức tự lo — đó là mẫu template method. Compiler chặn hai sai lầm quen thuộc:
1
2
3
4
5
6
7
8
9
10
abstract class PaymentMethod {
abstract pay(amount: number): string;
}
new PaymentMethod();
// ❌ error TS2511: Cannot create an instance of an abstract class.
class BankTransfer extends PaymentMethod {}
// ❌ error TS2515: Non-abstract class 'BankTransfer' does not implement
// inherited abstract member pay from class 'PaymentMethod'.Khi nào chọn abstract class thay vì interface? Khi bạn có logic hoặc state dùng chung (như receipt) và muốn ép class con điền vào vài chỗ trống. Nếu chỉ cần một hợp đồng thuần, không kèm logic, thì interface gọn hơn — phần tiếp theo.
5. implements: interface là hợp đồng, class là bản thi công
interface mô tả một class phải có gì; implements là lời cam kết class đáp ứng đúng hợp đồng đó. Một class implement được nhiều interface cùng lúc:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface Logger {
log(msg: string): void;
}
interface Serializable {
toJSON(): object;
}
class AuditLog implements Logger, Serializable {
private entries: string[] = [];
log(msg: string) {
this.entries.push(msg);
}
toJSON() {
return { entries: this.entries };
}
}
const audit = new AuditLog();
audit.log("user login");
audit.log("user logout");
console.log(JSON.stringify(audit));Chạy thử: in ra {"entries":["user login","user logout"]}. AuditLog cam kết cả hai hợp đồng, và nếu thiếu một method, compiler tuýt còi ngay:
1
2
3
4
5
6
7
interface Logger {
log(msg: string): void;
}
class BrokenLog implements Logger {}
// ❌ error TS2420: Class 'BrokenLog' incorrectly implements interface 'Logger'.
// Property 'log' is missing in type 'BrokenLog' but required in type 'Logger'.Điểm mấu chốt: implements chỉ kiểm tra hình dạng, không thêm code. Nó không cho class thừa hưởng logic như extends — mỗi class implement phải tự viết đủ.
5.1. interface hay type cho hợp đồng?
Cả interface lẫn object type đều dùng được với implements. Nhưng union type thì không:
1
2
3
4
5
type Shape = { kind: "circle" } | { kind: "square" };
class MyShape implements Shape {}
// ❌ error TS2422: A class can only implement an object type or
// intersection of object types with statically known members.Khuyến nghị: dùng interface cho hợp đồng mà class implement — nó hỗ trợ extends, cho phép declaration merging, và đọc quen mắt cho một hợp đồng. Để dành type cho union, tuple, mapped type và utility type. Phần cú pháp type nâng cao đi kèm (union, generic, utility) nằm ở bài TypeScript Types Nâng Cao Cho React.
6. static, getter/setter và generic class
6.1. static, static block và getter/setter có type
static gắn member vào class, không phải instance. static { ... } là static block — chạy đúng một lần khi class được định nghĩa, hợp cho khởi tạo phức tạp. Getter/setter thì có type như mọi method, và setter là chỗ lý tưởng để validate:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Config {
static readonly version = "1.0.0";
private _timeout = 3000;
static {
// chạy một lần khi class được nạp
console.log(`Config module loaded — version ${Config.version}`);
}
get timeout(): number {
return this._timeout;
}
set timeout(ms: number) {
if (ms < 0) throw new Error("timeout không được âm");
this._timeout = ms;
}
}
const c = new Config();
c.timeout = 5000; // đi qua setter, được validate
console.log("timeout =", c.timeout); // đi qua getterChạy thử: in ra Config module loaded — version 1.0.0 (từ static block) rồi timeout = 5000. Gán c.timeout = -1 sẽ ném lỗi ngay tại setter, nên state của object không bao giờ rơi vào giá trị vô nghĩa.
6.2. Generic class
Khi một class làm cùng một việc trên nhiều kiểu dữ liệu, cho nó một type parameter thay vì viết lại từng bản. Một Repository<T> là ví dụ kinh điển:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Repository<T extends { id: string }> {
private items = new Map<string, T>();
add(item: T): void {
this.items.set(item.id, item);
}
get(id: string): T | undefined {
return this.items.get(id);
}
all(): T[] {
return [...this.items.values()];
}
}
type Product = { id: string; name: string; price: number };
const products = new Repository<Product>();
products.add({ id: "p1", name: "Bàn phím", price: 500 });
products.add({ id: "p2", name: "Chuột", price: 200 });
console.log(products.get("p1"));
console.log("Số sản phẩm:", products.all().length);Chạy thử: in ra { id: 'p1', name: 'Bàn phím', price: 500 } rồi Số sản phẩm: 2. Ràng buộc T extends { id: string } bảo đảm mọi item đều có id để làm key — products.get("p1") trả về đúng type Product | undefined, không phải any.
7. Composition over inheritance: ghép thay vì kế thừa sâu
Kế thừa nhiều tầng (A extends B extends C) nghe gọn lúc đầu nhưng nhanh chóng thành cây cứng nhắc: đổi class cha là rung cả nhánh con, và một class chỉ extends được một cha. Cách thường bền hơn là ghép hành vi qua constructor — chính là parameter property + dependency injection ở mục 3:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ConsoleNotifier {
send(msg: string): void {
console.log(`[notify] ${msg}`);
}
}
class OrderService {
// nhận notifier từ ngoài, không kế thừa nó
constructor(private readonly notifier: ConsoleNotifier) {}
checkout(orderId: string): void {
this.notifier.send(`Đơn ${orderId} đã được đặt`);
}
}
new OrderService(new ConsoleNotifier()).checkout("A123");Chạy thử: in ra [notify] Đơn A123 đã được đặt. OrderService không extends ConsoleNotifier; nó nhận một notifier qua constructor. Muốn đổi sang gửi email, bạn viết một EmailNotifier có cùng method send rồi truyền vào — OrderService không phải sửa một dòng. Đó là điều mà một cây extends khó cho bạn.
8. Decorator: dùng bản chuẩn, không phải bản experimental
Từ TypeScript 5.0, decorator chuẩn (đề xuất TC39 stage 3) chạy thẳng, không cần cờ nào trong tsconfig:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Standard decorator (TS 5+) — không cần experimentalDecorators
function logged<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
const name = String(context.name);
return function (this: This, ...args: Args): Return {
// chỗ này gọi logger thật trước khi chạy method gốc; ví dụ giữ ngắn
void name;
return target.call(this, ...args);
};
}
class Calculator {
@logged
add(a: number, b: number): number {
return a + b;
}
}Decorator chuẩn nhận (target, context) — context.kind cho biết đang trang trí method, field, getter hay cả class, còn context.name là tên member. Đây là API duy nhất bạn nên học cho code mới.
Bản cũ — bật bằng experimentalDecorators — vẫn còn, và bạn sẽ gặp nó nếu dùng NestJS, Angular hay TypeORM. Các framework này dựa vào emitDecoratorMetadata + thư viện reflect-metadata để đọc type lúc runtime, mà decorator chuẩn không hỗ trợ. Vì thế chúng vẫn cần cặp cờ:
1
2
3
4
5
6
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Chọn thế nào cho gọn: code mới, không dùng framework đòi metadata thì dùng decorator chuẩn và bỏ hẳn hai cờ trên. Đang trong một dự án NestJS/Angular thì giữ nguyên experimentalDecorators, vì cả hệ sinh thái xây trên nó — trộn hai kiểu trong một project chỉ tổ rối.
Lưu ý: useDefineForClassFields ảnh hưởng class field
Một điểm dễ vấp liên quan class field: useDefineForClassFields mặc định bật khi target là ES2022 trở lên (hoặc ESNext), và tắt với target thấp hơn. Khi bật, class field được khởi tạo bằng Object.defineProperty đúng chuẩn ECMAScript thay vì phép gán thường — khác biệt lộ ra khi field trùng tên với getter/setter của class cha. Nếu một ngày bạn thấy field “bỗng thành undefined“ sau khi đổi target, hãy soi ngay cờ này.
Tóm lại: private là kỷ luật lúc compile, còn #field mới là đóng gói thật ở runtime; abstract để chia sẻ logic chung và ép class con điền chi tiết, trong khi implements chỉ ràng buộc hình dạng chứ không cho thừa hưởng code; và khi cây kế thừa bắt đầu sâu, composition qua constructor thường gọn và dễ đổi hơn. Hãy mở một class bất kỳ trong dự án của bạn, đổi những private thật sự cần giấu thành #, và thử biến một quan hệ extends gượng ép thành một field được tiêm vào — code sẽ dễ test hơn bạn tưởng.