Typescript: Class, Interface & chúng khác gì với class, interface trong C#, Java (!?)

OK.

Từ trước tới giờ, nếu ai đã từng viết Javascript thì sẽ biết rằng: không giống như rất nhiều các ngôn ngữ lâp trình phổ biến hiện nay, Javascript không có khái niệm về Interface cũng như Class (update: khái niệm Class mới được đưa vào JS kể từ ES6).

Typescript, ngôn ngữ do hãng Microsoft phát triển (chính xác là do team của Anders Hejlsberg - tác giả ngôn ngữ C# - xây dựng nên). Vì vậy, thật dễ hiểu khi Typescript - vốn được thiết kế như một superset của javascript - nay cũng được "cài cắm" thêm nhiều tính năng khác, trong đó có thể kể đến hai thứ mà ta sẽ nói tới trong bài viết này - interface và class. Tuy nhiên, vì xuất phát điểm ban đầu của javascript không phải là 1 ngôn ngữ hướng đối tượng hoàn toàn như C#, java ... Vì thế, khái niệm về interface cũng như class được implement vào trong Typescript chắc chắn cũng sẽ có những điểm khác biệt so với khái niệm mà ta đã quen thuộc ở các ngôn ngữ kia.

Class

Javasript thông thường sử dụng function và nguyên lý kế thừa dựa trên prototype để viết nên các component có khả năng sử dụng lại. Còn đối với các dev đã quen với các ngôn ngữ OOP như C#, java ... việc kế thừa được xây dựng dựa theo kế thừa của các class.

Typescript (và sau này là ES6) đưa vào khái niệm Class cho javascript:

class Human {
    name: string;
    
    constructor(name: string) {
        this.name = name;
    }
    
    say() {
        console.log "hello, my name is: " + this.name;
    }
}

...
let  John = new Human('John');

Cú pháp trên giống hoàn toàn như khi ta viết với C# hay java. Ta khai báo một class mới Human. Class này bao gồm 3 member: 1 property name, hàm khởi tạo và method say. Cuối cùng, ta khởi tạo đối tượng thuộc lớp này bằng từ khóa new.

Khi được dịch ra javascript để chạy, đoạn code trên sẽ trở thành như sau:

var Human = (function() {
    function Human(name) {
        this.name = name;
    }
    
    Human.prototype.say = function() {
        console.log("hello, my name is: " + this.name);
    };
    return Human;
})();

Từ đoạn code trên, ta có thể thấy rằng Bản chất của class trong Typescript cũng chính là việc sử dụng self-invoke function cùng với prototype của javascript.

kế thừa (inheritance) class

Đã có class thì phải có inheritance class. Nguyên lý kế thừa trong Typescript cũng giống các ngôn ngữ khác:

class Teacher extends Human {
    say() {
        super.say();
        console.log(' and I'm a teacher');
    }
}
  • Ta khai báo kế thừa lớp bằng từ khóa extends.
  • trong class con, ta có thể gọi tới function của class cha bằng thông qua super.

Khác #1: public, private, protected modifier

Với ví dụ ở trên, ta hoàn toàn có thể gọi tới property của class Human như sau:

let  John = new Human('John');
let name = John.name;

Khác với C#: Đối với class trong Typescript, nếu không có khai báo gì thêm, 1 property được mặc định coi như là public

Bên cạnh đó, ta cũng có các modifier private, protected cho property, đồng thời cũng có thể dùng modifier public để tăng readable cho code.

class Human {
    public name: string;   // public ở đây là optional
    ...
}

Với private, property không thể bị gọi từ ngoài vào.

class Human {
    private name: string;
    ...
}

...
let  John = new Human('John');
let name = John.name;                   //  Error: 'name' is private;

Với protected, nó giống với private với 1 ngoại lệ là các property này có thể được gọi tới từ bên trong lớp con kế thừa từ lớp cha.

class Human {
    protected name: string;
    ...
}

class Teacher extends Human {
    say() {
        this.name;                     //  => đoạn này sẽ bắn error nếu name được khai báo private ở trên.
    }
}

Trick #1: parameter property

việc khai báo 1 property, đồng thời gán giá trị cho nó ở constructor là việc thường thấy:

class Human {
    name: string;
    
    constructor(name: string) {
        this.name = name;
    }
}

Pattern code này được sử dụng thường xuyên, nhiều đến nỗi Typescript đã cung cấp 1 cách gõ tắt: cho phép tạo và khởi tạo property ở cùng 1 chỗ. Đoạn code có thể viết lại ngắn hơn như sau:

class Human {
    constructor(public name: string) {
        this.name = name;
    }
}

Tương tự như thế, sử dụng các modifier khác ở constructor cũng sẽ tạo cho ta các property tương ứng: private, protected, readonly.

Interface

Cùng với class, Typescript đưa vào thêm khái niệm Interface. Tương tự với các ngôn ngữ khác, interface trong typescript cũng được sử dụng như 1 công cụ để tạo sự ràng buộc, quy ước giữa các thành phần tương tác trong hệ thống. Quy ước này được thể hiện qua 1 câu nói:

“If it looks like a duck, and quacks like a duck, it’s a duck.”

Nói cách khác, interface được dùng như 1 cái giao kèo, đảm bảo rằng 1 đối tượng nào đó sẽ có các thành phần / cấu trúc như đã quy ước.

Để dễ hiểu, ta sử dụng ví dụ sau:

Tại sao lại cần tới interface ?

Code javascript, hẳn ta rất quen thuộc với bài toán : bên phía client, lấy dữ liệu dạng JSON về từ server, sau đó làm các thứ với dữ liệu này. Giả sử , ta cần viết 1 hàm nhận vào là 1 Post và đầu ra là in ra cái gì đó được sinh ra từ post này:

function tomTat( post: {content: string} ) {
    return post.content.slice(0,  10);    // trả về tóm tắt nội dung bài post với max là 30 kí tự đầu tiên.
}

let post = { content: 'this is a very long long long long post.' }
tomTat(post);

Để ý rằng: để viết được logic của hàm tomTat, trong params của tomTat ta phải khai báo kiểu object sẽ được truyền vào: post: {content: string} Lúc này, interface có thể được sử dụng để viết lại hàm trên ngắn gon hơn.

interface Post {
    content: string;
}

function tomTat( post: Post ) {
    return post.content.slice(0,  10);    // trả về tóm tắt nội dung bài post với max là 30 kí tự đầu tiên.
}

Bằng cách sử dụng interface Post, ta tạo 1 quy ước cho đầu vào của function tomTat. interface Post ở đây cũng có thể tái sử dụng ở những chỗ khác trong chương trình.

Mà khoan ! Có gì đó sai sai ...

Khác #2: property trong Interface

Từ ví dụ trên , ta có thể thấy: trong Typescript, Interface có thể chứa cả property.

Nếu ai đã quen thuộc với Java thì sẽ biết rằng, trong java, interface không thể chứa property (hay trong java gọi là attribute). Một interface sẽ chỉ chứa các abstract method, là các ràng buộc mà class buộc phải implement:

In Java - However, an interface is different from a class in several ways, including −

  • You cannot instantiate an interface.
  • An interface does not contain any constructors.
  • All of the methods in an interface are abstract.
  • An interface cannot contain instance fields. The only fields that can appear in an interface must be declared both static and final.
interface Animal {
   public void eat();
   public void travel();
}

Mặt khác , đối với Typescript

One of TypeScript’s core principles is that type-checking focuses on the shape that values have. This is sometimes called “duck typing” or “structural subtyping”. In TypeScript, interfaces fill the role of naming these types, and are a powerful way of defining contracts within your code as well as contracts with code outside of your project.

Interface lúc này đóng vai trò tạo nên ràng buộc cho các class implement lại nó, không chỉ là về các phương thức , mà còn cả về hình dạng (shape) của đối tượng được implement.

Optional property

Property trong interface có thể không bắt buộc.

interface CarConfig{
        name?: string;
        year?: numeric;
}

function createCar(config: CarConfig) {
    let newCar = { name: 'Toyota', year: 1984 }
    if (config.name) { newCar = config.name } 
    if (config.year) { newCar = config.year } 
    return newCar;
}

...
let newCar = createCar({ name: 'Suzuki' });

Optional property trong interface được đánh dấu bởi dấu ?. Ưu điểm của optional property là nó cho phép ta khai báo các property có thể xuất hiện, đồng thời ngăn việc sử dụng các property mà chắc chắn không thuộc trong interface. Một ví dụ điển hình là việc lập trình viên có thể gõ sai chính tả tên 1 property nào đó.

interface CarConfig{
        name?: string;
        year?: numeric;
}

function createCar(config: CarConfig) {
    let newCar = { name: 'Toyota', year: 1984 }
        if (config.name) { newCar = config.nane }       // => ở chỗ này, editor hoặc trình biên dịch sẽ thông báo ngay lỗi : Property 'nane' does not exist on type 'CarConfig'
    if (config.year) { newCar = config.year } 
    return newCar;
}

...
let newCar = createCar({ name: 'Suzuki' });

function type trong interface

Ngoài property, interface cũng có thể mô tả object thông qua việc khai báo các function.

Khai báo function trong interface chỉ là đưa ra chữ kí (signature) của function đó : bao gồm danh sách các params và kiểu trả về của function.

interface IRun {
    (speed: numeric, destination: string): numeric;
}

let runner: IRun;

runner = function(speed: numeric, destination: string): numeric {
    // ... implement nội dung function
}

class type trong interface

UseCase phổ biến nhất của interface đó là buộc một class phải tuân theo 1 quy ước khi implement.

interface IRun {
   startTime: Date;
   run(speed: numeric, destination: string): numeric;    
}

class Runner implements IRun {
   startTime: Date;
   
   run(speed: numeric, destination: string): numeric {
      // return a numeric here !
  }
}

interface extend interface

Cũng giống class, các interface có thể extend nhau. Một interface có thể extend cùng lúc nhiều interface khác.

interface Shape {
 cornerNumber: string;
}

interface Color {
 borderColor: string;
}

interface Square extends Shape, Color {
 sideLength: number;
}

let square = <Square>{};
square.cornerNumber = 4;
square.borderColor = "blue";
square.sideLength = 10;

Khác #3: interface extend class

Không chỉ extends được interface khác, interface trong Typescript cũng có khả năng extend được class !! Khi extend 1 class, interface sẽ kế thừa các member (property, function) của class đó, không ko phải implementation của chúng.

Chú ý: Các property được kế thừa sẽ bao gồm cả các property private và protected, lúc này, interface chỉ có thể implement được bởi chính class đó hoặc các class con của nó.

class Parent {
     private privateProperty;
 }

 interface ISomething extends Parent {
     doSomething(): void;
 }

 class FirstChild implements ISomething {
     // Ta sẽ gặp lỗi ở đây: 
     //   Class 'FirstChild' incorrectly implements interface 'ISomething'.
     //   Property 'privateProperty' is missing in type 'FirstChild'

     doSomething() {
       // do something
     }
 }
 
 // Vẫn lỗi 
 class SecondChild extends ISomething {
     
     privateProperty;        //   Class 'SecondChild' incorrectly implements interface 'ISomething'.
                                     //   Property 'privateProperty' is private in type 'ISomething' but not in type 'SecondChild'
     doSomething() {
       // do something
     }
 }

 // Ngay cả khai báo thế này cũng không ăn thua :v
 class ThirdChild extends ISomething {
     
     private privateProperty;        //   Class 'ThirdChild' incorrectly extends baseclass 'Parent'.
                                     //   Types have seperate declarations of a private property 'privateProperty'
     doSomething() {
       // do something
     }
 }

 // Ta bắt buộc phải khai báo thế này để sử dụng đc interface
 class ForthChild extends Parent implements ISomething {
     domeSomething() {
        // làm gì thì làm ...
     }
 }

Khác #4: Type compatibility

Đọc đoạn code dưới đây, lập trình viên C#, Java sẽ thấy ngay có gì đó sai sai :

interface Named {
    name: string;
}

class Human {
    name: string;
}

let boy: Named;

boy = new Person();

Chuyện gì xảy ra vậy !? class Human và interface Named chả có liên quan gì tới nhau cả ??

Trên thực tế, cấu trúc của Typescript được xây dựng dựa trên cách Javascript thường được viết. Trong Javascript, các anonymous object được sử dụng rất rộng rãi, điển hình là việc sử dụng các JSON object được lấy về dưới dạng kết quả của API call. Vì thế nó dẫn tới quy ước sau được sử dụng trongTypescript.

x được coi là compatible với y nếu y chứa (ít nhất) đầy đủ các member của x

Ví dụ:

interface Named {
    name: string;
}

let x: Named;
let y  = { name: 'Alice', location: 'Seattle' } ;

x = y;   // => OK, bởi vì y chứa property name của interface Named

Để kiểm tra xem y có thể assign được cho x không, compiler sẽ kiểu tra từng property của x để tìm property tương ứng trong y. Như trong ví dụ trên, y ít nhất phải có 1 member name dạng string.

Chú ý rằng y có thêm property location nữa, nhưng điều này là được cho phép và sẽ không bị compile báo lỗi.

So sánh 2 function

Nếu như việc so sánh các member ở dạng đơn giản như string, boolean hay number ... là khá đơn giản, câu hỏi đặt ra lúc này là: khi nào thì 2 function được coi là compatible.

Bắt đầu với ví dụ nhỏ sau:

let x = (a: number) => 0;

let  y = (b: number, c: string) => 0;

y = x ;  // OK
x = y; // Error !

Đầu tiên khi kiểm tra xem 2 function có assign được cho nhau không, ta sẽ kiểu tra parameter list đâu tiên. Mỗi parameter trong x phải có 1 parameter tương ứng trong y. (chú ý 2 parameter chỉ cần tương ứng về type, không cần phải trùng tên) Vì trong ví dụ, y có 1 param thuộc type stringx không có => compiler sẽ báo lỗi.

Ngược lại, việc để thừa params vẫn được chấp nhận, nhưng trong trường hợp y = x. Lý do cho việc này là bởi vì hành vi ignore function parameters cũng thường gặp trong javascript. Ví dụ nhưng hàm forEach của javascript

let items = ['a', 'b', 'c'];

items.forEach((item, index, array) => console.log(item));  // in ra 'a', 'b', 'c'

items.forEach(item => console.log(item));    // như thế này cũng vẫn được ! 2 param index và array có thể bỏ qua. 

Tiếp theo , ta so sánh kiểu trả về của function :

let x = () => ( { name: 'Tom' });
let y = () => ( { name: 'Tom', age: 17 } );

x = y ; // OK , pass.
y = x; // Error !

Trong trường hợp trên, type system báo lỗi, yêu cầu kiểu trả về của function x phải chứa đầy đủ các thành phần của kiểu trả về của y.

Dùng interface hay class ! Khi nào ?

Ok. Bây giờ, sau khi đã tìm hiểu qua về 2 khái niệm Class, Interface và 1 số điểm khác biệt của chúng trong Typescript, tiếp theo, ta sẽ so sánh 2 khái niệm này và các trường hợp nên sử dụng chúng.

Quay trở lại với các ngôn ngữ OOP truyền thống (C#, java ...): Mục đích của interface trong các ngôn ngữ này đó là tạo ràng buộc về mặt hành vi. Một Class implement 1 interface thì bắt buộc phải implement các hành vi nói trên.

Ngược lại, trong Typescript, interface bao gồm cả các ràng buộc về behavior (function) cũng như structure của Class (property)

Hay nói cách khác, 2 cách viết dưới đây hoàn toàn có thể chấp nhận.

interface Human {
    name: string;
}

class Teacher implements Human {
    name: string;
}
class Human {
    name: string;
}

class Teacher extends Human {
  // ...
}

Thực vậy, trong Angular Style Guide , class được khuyến khích sử dụng thay vì interface:

Why? TypeScript guidelines discourage the I prefix.

Why? A class alone is less code than a class-plus-interface.

Why? A class can act as an interface (use implements instead of extends).

Why? An interface-class can be a provider lookup token in Angular dependency injection.

Viết class còn ngắn hơn viết interface, đồng thời mọi thứ của interface có thể thực hiện được bằng class, vậy interface để làm gì ?

Vậy thì trong trường hợp nào, interface nên được sử dụng?

Cho đến lúc này, khi viết code, việc sử dụng class hay interface là không có gì khác việt. Sự khác biệt chỉ xảy ra khi compile code từ Typescript thành javascript để chạy:

The real different comes when we consider our compiled Javascript output.

Sử dụng ví dụ dưới đây: ta có khai báo 1 interface Response để định dạng kiểu dữ liệu trả về từ API:

interface Resposne {
    status: number;            // 200, 401, 404 ...
    message: string;
}

fetch('https://my-api.com').then((response: Response) => {
    if (response.status == 200) {
        console.log(response.message);
    }
});

Compile đoạn code trên về javascript, mã đích của ta sẽ như sau

fetch('https://my-api.com').then(function (response) {
    if (response.status == 200) {
        console.log(response.message);
    }
});

Ta không có thấy bóng dáng của interface Response đâu cả ! Interface trong Typescript sẽ chỉ có vai trò nhắc hint, báo lỗi khi viết code bằng editor cũng như trong quá trình compile. Sau đó, nó sẽ được loại bỏ khỏi mã đích javascript.

Ngược lại, không giống như interface, một class của Typescript sẽ sinh ra 1 Javascript construct thực sự (quay lại ví dụ phía đầu bài viết). Với ví dụ trên, nếu đổi interface thành class, ta sẽ sinh ra mã javascript sau:

var Response = (function() {
    function Response() {        
    }
    return Response;
})();

fetch('https://my-api.com').then(function (response) {
    if (response.status == 200) {
        console.log(response.message);
    }
});

Sau khi compiler dịch code từ Typescript sang javascript, class của ta sẽ biến thành dạng function trong javascript, được lưu trong mã nguồn cuối cùng của chương trình ! Đoạn code khai báo biến Response trên hoàn toàn thừa thãi và làm tăng dung lượng mã đích.

Nếu ta có 1 application đủ lớn, và sử dụng bừa bãi class như một model type annotation, thì sẽ dẫn tới kết quả là dung lượng chương trình được biên dịch phình to ra đáng kể !

=> Chỉ sử dụng Class khi ta có logic nghiệp vụ thực sự cần được implement để thực thi. Ngược lại, nếu chỉ dùng nó để tạo 1 ràng buộc kiểu cho params hay variable, ta nên dùng Interface.

Kết luận

Tổng kết lại, sau 1 bài dài, chúng ta đã tìm hiểu được:

  • Class, Interface trong Typescript là gì và thực chất của chúng trong javasript là gì.
  • Những điểm mới của Class, Interface trong Typescript, và cả những điểm trái ngược hoàn toàn với khái niệm của chúng trong các ngôn ngữ OOP truyền thống mà ta cần chú ý.
  • Khi nào nên dùng Class hoặc Interface.

Tài liệu tham khảo: