TypeScript căn bản

TypeScript là một dự án kéo dài hơn 3 năm của Microsoft nhằm tạo ra một ngôn ngữ để mở rộng JavaScript, khiến nó trở nên phù hợp hơn với những ứng dụng lớn. Trưởng nhóm dự án này là Anders Hejlsberg, cha đẻ của C#, Turbo Pascal và Delphi. Với bề dày kinh nghiệm của bác ấy thì “độ mạnh” của TypeScript là khỏi bàn cãi.

Thực ra, TypeScript không phải là tay chơi duy nhất trong thị trấn JavaScript này. Đúng hơn là nó sinh sau đẻ muộn so với vài đàn anh khác như CoffeeScriptDart của Google. Hai anh chàng này ra đời cách đây khá lâu và cũng gây được tiếng vang, đặc biệt là anh CoffeeScript. Tuy nhiên, hai chàng này không duy trì cú pháp của JavaScript mà lại thay đổi, biến tấu sang cú pháp mới khiến chúng trở thành ngôn ngữ rất khác so với JavaScript. Với TypeScript, ta có thể copy nguyên xi code JavaScript và chạy bình thường, bởi vì TypeScript duy trì cú pháp của JavaScript và mở rộng nó ra bằng một loạt tính năng mới.

Điều này cho phép ta phối hợp TypeScript và JavaScript với nhau trong cùng một file. Nếu như đã có sẵn code JavaScript và cảm thấy lười viết lại để sử dụng tính năng mới của TypeScript, bạn có thể để y nguyên mã JavaScript vào trong TypeScript và chúng vẫn chạy ngon lành. Viết lại code là một cực hình trong cuộc đời lập trình viên. TypeScript hiểu được điều này nên nó không bắt bạn phải làm thế nếu bạn không muốn.

Như đã nói ở trên, TypeScript không phải là phiên bản mới của JavaScript mặc dù nó sử dụng cú pháp na ná JavaScript mà rất nhiều người đã quen thuộc hiện nay. Với TypeScript, ta có thể dùng mã JavaScript hiện có, tích hợp thư viện JavaScript phổ biến như jQuery và dùng như một package trong Node.js. Cái gì có thể làm trong JavaScript thì cũng có thể làm trong TypeScript mà không gặp bất kì khó khăn nào.

Mã JavaScript được sinh ra bởi TypeScript có thể chạy trên mọi môi trường có hỗ trợ JavaScript mà không cần chỉnh sửa. Mã JavaScript được tạo ra nhỏ gọn và đúng chuẩn nên ta hoàn toàn yên tâm về chất lượng (cha đẻ của C# thiết kế thì ta khỏi phải lo). Bên cạnh đó, TypeScript hỗ trợ kiểu tĩnh (static type) so với kiểu động (dynamic type) trong JavaScript. Việc này rất có lợi trong quá trình debug và test các ứng dụng lớn.

Nếu bạn là lập trình viên dùng một ngôn ngữ OOP truyền thống (C# chẳng hạn), thì TypeScript sẽ khiến bạn cảm thấy như đang ở nhà. TypeScript hỗ trợ class, interface, property,… những thứ mà JavaScript không hỗ trợ. Giờ đây, việc viết code TypeScript sẽ trở nên gần giống với các ngôn ngữ họ nhà C (C++, C#,…) và những kĩ năng bạn sử dụng trong những ngôn ngữ đó có thể mang qua TypeScript để tận dụng dễ dàng. Do vậy, những cơn ác mộng về quản lý, bảo trì code JavaScript không còn khiến ta phải bận tâm.

Một điều mà tôi rất thích ở TypeScript là nó được các công cụ phổ biến hiện nay hỗ trợ rất mạnh. Nếu bạn đang dùng Visual Studio chắc chắn bạn sẽ được hỗ trợ tận răng từ IntelliSense cho tới debug và compile. Còn nếu bạn xài Sublime Text thì TypeScript cũng hỗ trợ luôn cho bạn. Ngoài ra, nếu sử dụng Node.js, bạn có thể tải về package của TypeScript và bắt đầu sử dụng ngay.

Cập nhật 2016: Trong thời gian qua, Microsoft cho ra mắt text editor mới tên là Visual Studio Code. Đây là công cụ tuyệt vời để viết code TypeScript nếu bạn không thích cài bản Visual Studio đồ sộ.

Sử dụng TypeScript

Nếu máy bạn có Visual Studio 2013 thì việc cài đặt thêm TypeScript vô cùng đơn giản. Ta chỉ cần vào trang web chính thức của TypeScript và download plugin cho Visual Studio. Sau đó, ta cài đặt như bình thường và Visual Studio sẽ có thêm một template mới mỗi khi tạo project.

Mặc dù TypeScript đã được “mở nguồn” để trở thành công nghệ “nguồn mở” nhưng Microsoft vẫn luôn ưu tiên cho đứa con cưng Visual Studio của mình. Đến bây giờ, công cụ hỗ trợ TypeScript đầy đủ nhất vẫn là Visual Studio. Do đó, nếu bạn muốn tận hưởng trải nghiệm thú vị với TypeScript, tôi khuyên bạn nên download và cài đặt Visual Studio nếu bạn đang dùng công cụ khác.

Bạn nên cài đặt bản Visual Studio 2013 Update 3 (hoặc mới hơn) để sử dụng TypeScript trong Visual Studio vì bản cập nhật này có cài luôn project template cho TypeScript. Nếu không, bạn phải vào trang TypeScript để download và cài thủ công.

Khi quá trình cài đặt hoàn tất, ta khởi động Visual Studio và tạo một project mới. Nhìn sang cột bên trái, ta sẽ thấy đề mục TypeScript. Bên dưới, ta đặt tên cho project và nhấn Enter. Lúc này, trong cửa sổ Solution Explorer sẽ liệt kê toàn bộ file có trong project vừa tạo. Các file này có vai trò như điểm khởi đầu để phát triển ứng dụng. Cấu trúc project của TypeScript rất đơn giản: một file app.css chứa CSS, file default.htm chứa HTML, và app.ts chính là file TypeScript mà ta sẽ dành nhiều thời gian để làm việc. Chắc bạn cũng có thể đoán ra, cái đuôi .ts là viết tắt của TypeScript. Khi mở file này lên, Visual Studio hiển thị nội dung của nó trong code editor.

TypeScript Solution Explorer

Thoạt nhìn, ta có cảm giác đoạn code khá giống JavaScript. Tuy nhiên, sau khi dụi mắt hai ba lần, ta phát hiện ra nó rất khác so với JavaScript. Trước tiên là từ khóa class nằm ngay đầu đoạn code. Rõ ràng, JavaScript không hỗ trợ tạo class như các ngôn ngữ hướng đối tượng truyền thống. Vậy đây chắc chắn là tính năng mới của TypeScript.

Kéo xuống phía dưới, ta lại bắt gặp thêm ký hiệu lạ ngay dòng window.onload như sau:

1 window.onload = () => {
2     ...
3 };

Chắc chắn cái thứ quái dị này chính là TypeScript. Nhưng khi ta nhìn vào phần thân hàm, ta thấy câu lệnh quá quen thuộc trong JavaScript:

1 ...
2 ... document.getElementById('content');
3 ...

Như vậy, ta có thể dùng TypeScript và JavaScript trà trộn với nhau. Thay vì học cú pháp của một ngôn ngữ mới, ta vẫn tận dụng được kiến thức JavaScript.

Quay lại cửa sổ Solution Explorer, ta bấm nút Show All Files trong dãy nút bấm phía trên cửa sổ này. Một loạt file ẩn hiện ra. Đặc biệt, ta có thêm một file app.js. Đây chính là file JavaScript đã được biên dịch từ app.ts. Lưu ý là file app.js chỉ được cập nhật mỗi khi Save thay đổi trong file app.ts.

Bây giờ, bạn có thể chạy ứng dụng qua chế độ debug bằng phím F5, trình duyệt mặc định sẽ được khởi động để hiển thị kết quả.

Kiểu động và kiểu tĩnh

Trong JavaScript, ta làm việc với kiểu động (dynamic type). Còn với TypeScript, ta làm việc với kiểu tĩnh (static type). Trong nhiều năm qua, cuộc chiến “võ mồm” liên tục diễn ra giữa hai phe ủng hộ kiểu động và kiểu tĩnh trên các diễn đàn trực tuyến. Quả thực, cái nào cũng có cái hay cái dở riêng. Trong phần này, tôi sẽ so sánh sự khác nhau giữa hai trường phái để bạn có cái nhìn sơ lược nhất.

Khái niệm kiểu động và kiểu tĩnh nghe có vẻ phức tạp nhưng thực chất nó rất đơn giản. Biến có kiểu động là biến có thể chứa bất kì kiểu giá trị nào mà không bị giới hạn bởi một kiểu nhất định. Hãy xét đoạn code JavaScript sau:

1 var a = 1;
2 a = "Joe";

Ban đầu, biến a chứa giá trị số 1, nhưng sau đó nó được gán giá trị chuỗi Joe. Bây giờ, ta thử nhập đoạn code trên vào TypeScript, ngay lập tức danh sách Error List của Visual Studio sẽ hiển thị thông báo lỗi Cannot convert 'string' to 'number'. Đây chính là tính chất của biến kiểu tĩnh. Một biến khi đã được xác định kiểu thì chỉ có thể chứa các giá trị có kiểu đó.

Với những người không quen dùng ngôn ngữ lập trình sử dụng kiểu tĩnh thì khi tiếp xúc lần đầu với khái niệm này, họ cảm thấy bị gò bó và hạn chế. Tuy nhiên, sự khắt khe của kiểu tĩnh có cái lợi của nó. Khi một biến được khai báo kiểu rõ ràng, ta luôn biết được kiểu của giá trị mà nó chứa mà không cần phải kiểm tra hay đoán già đoán non. Một biến có thể chứa nhiều kiểu giá trị khác nhau tuy mang lại sự linh hoạt nhưng nó cũng khiến cho việc bảo trì trở nên phức tạp hơn, đặc biệt là khi debug để tìm lỗi có liên quan tới kiểu.

Một điểm khác biệt nữa trong TypeScript đó là lỗi về kiểu sẽ được phát hiện trong quá trình viết code hoặc khi biên dịch (compile-time) sang JavaScript nhờ sự trợ giúp của Visual Studio. Còn trong JavaScript, ta phải đợi tới khi chạy (run-time) thì mới biết, và cũng có khi chẳng bao giờ biết. Hãy xem qua ví dụ sau để hiểu rõ hơn vấn đề này:

1 function add(a, b) {
2     return a + b;
3 }

Do sử dụng kiểu động, ta có thể truyền tham số với kiểu số hay chuỗi đều được. Thậm chí ta có thể truyền một tham số là kiểu số, tham số còn lại là kiểu chuỗi và JavaScript không hề báo mội lỗi nào.

1 add(1, 2);
2 add("Hieu ", "Sensei");
3 add("Hieu ", 2014);

Cả 3 câu lệnh trên đều hợp lệ trong JavaScript do nó sử dụng biến kiểu động. Rõ ràng, ta có thể thấy được sự linh hoạt của kiểu động. Chỉ cần định nghĩa một function mà có thể trả về nhiều loại giá trị khác nhau. Tuy nhiên, cái gì cũng có giá của nó. Linh hoạt trả về các giá trị khác nhau khiến cho việc bảo trì trở nên khó khăn hơn, và khả năng xảy ra lỗi sẽ tăng cao khi ứng dụng trở nên phức tạp.

Một điểm bất lợi khác khi sử dụng kiểu động đó là công cụ nhắc mã IntelliSense sẽ không hỗ trợ khi viết code vì chức năng này dựa vào kiểu của biến để tìm các method và property. Khi dùng kiểu động, biến sẽ không có một kiểu cố định, nên IntelliSense không thể cung cấp nhiều thông tin hỗ trợ. Giả sử tôi có một hàm sau đây:

1 var reverseName = function(name) {
2     var length = name.length;
3     ...
4 };

Do không biết biến name có kiểu gì nên khi gõ name rồi dấu chấm phía sau, tính năng nhắc mã IntelliSense không hiển thị method và property của name. Trong trường hợp này, lập trình viên phải tự xử bằng cách cố nhớ tên method hoặc property cần dùng rồi gõ vào cho chính xác.

Quả thật, kiểu động thì có linh hoạt nhưng chưa chắc là có lợi. Do đó, TypeScript khắc phục điểm này của JavaScript và sử dụng kiểu tĩnh cho tất cả các biến.

Khai báo kiểu cho biến

Khai báo biến trong TypeScript hoàn toàn tương tự như JavaScript. Ta thực hiện như sau:

1 var name = "Joe";

Từ khóa var để báo cho TypeScript biết là ta đang khai báo biến mới. Phía sau từ khóa var là tên biến ta tự đặt, và cuối cùng là giá trị ban đầu gán cho biến vừa tạo. Ở đây tôi gán giá trị chuỗi Joe và biến name sẽ có kiểu string. Làm thế nào tôi biết điều đó? Rất đơn giản, TypeScript xét giá trị gán vào biến name và phát hiện ra đó là một string. Do vậy, biến name sẽ có kiểu string. Cách tạo biến thế này tương tự JavaScript. Tuy nhiên, TypeScript có một cú pháp khai báo biến khác:

1 var name: string = "Joe";

Đây chính là kiểu khai báo biến trong TypeScript. Phía sau tên biến, ta thêm dấu hai chấm và theo sau đó là kiểu của biến. Nếu khai báo thế này, ta đã xác định rõ ràng kiểu của namestring và sau này, ta không thể gán một giá trị số. Như vậy, TypeScript đã biến JavaScript từ ngôn ngữ có kiểu động (dynamic type) sang kiểu tĩnh (static type) tương tự như C++ hay C#. Đây chính là cách khai báo biến nên dùng khi sử dụng TypeScript.

Một điều khác cần lưu ý đó là nếu không khai báo kiểu của biến và không gán giá trị ban đầu cho nó thì mặc định nó sẽ có kiểu any. Chỉ cần rê chuột lên tên biến, Visual Studio sẽ hiển thị một tooltip cho biết kiểu của biến đó.

1 var name; // Biến này có kiểu any.

Khi dùng Visual Studio, ta có thể tách khung soạn thảo code ra làm hai phần. Một phần dùng để viết code TypeScript, phần còn lại sẽ tự động hiển thị code JavaScript được biên dịch. Với tính năng này, ta vừa viết code TypeScript vừa có thể xem code JavaScript được sinh ra khi nhấn Save.

Khai báo kiểu của tham số và kiểu trả về của hàm

Cú pháp khai báo kiểu của biến cũng có thể dùng khi khai báo tham số cho hàm:

1 function getFullName(firstName: string, lastName: string) {
2     return firstName + " " + lastName;
3 }

Như bạn thấy, TypeScript khiến ta viết code cẩn thận và chi tiết hơn. Do đó, ta đỡ phải đau đầu mỗi khi debug. Cú pháp của JavaScript không chặt chẽ và đôi khi quá “dễ tính” nên thường dẫn đến những rắc rối về sau. Ngoài ra, ta cũng có thể khai báo kiểu trả về cho hàm bằng cú pháp tương tự:

1 function getFullName(firstName: string, lastName: string): string {
2     return firstName + " " + lastName;
3 }

Thông thường, ta không cần khai báo kiểu trả về của hàm vì TypeScript tự động xem kiểu của giá trị sau từ khóa return và biết ngay kiểu đó là gì. Ta cũng có thể gán giá trị mặc định cho các tham số:

1 function getFullName(firstName = "Joe", lastName = "Doe") {
2     return firstName + " " + lastName;
3 }
4 
5 getFullName();

Cách khai báo kiểu của biến trong TypeScript không có gì phức tạp, nó chỉ trông hơi lạ. Thực ra, những ý tưởng này không có gì mới và nếu bạn có kiến thức về một trong những ngôn ngữ kiểu tĩnh như C#, Java hay C++ thì những khái niệm này sẽ quen thuộc với bạn.

Cách thức khai báo hàm

Khai báo hàm trong TypeScript hoàn toàn tương tự như trong JavaScript. Tuy nhiên, như đã trình bày ở trên, ta có thể khai báo kiểu cho các tham số truyền vào trong hàm. Cụ thể, ta thực hiện như sau:

1 function getFullName(firstName: string, lastName: string) {
2     return firstName + " " + lastName;
3 }

Ngoài ra, ta có thể dùng hàm vô danh (anonymous function) và gán nó vào một biến:

1 var getFullName = function(firstName: string, lastName: string) {
2     return firstName + " " + lastName;
3 };

Hai cách trên rất quen thuộc nếu bạn từng dùng JavaScript. Bênh cạnh đó, TypeScript còn một cách khai báo hàm khá lạ:

1 var getFullName = (firstName: string, lastName: string) => {
2     return firstName + " " + lastName;
3 };

Trong ví dụ trên, ta bỏ đi từ khóa function và sử dụng dấu mũi tên => ở trước phần thân hàm. Ta thậm chí có thể rút gọn cú pháp trên hơn nữa:

1 var getFullName = (firstName: string, lastName: string) => firstName + " " + lastName;

Ta bỏ cặp dấu ngoặc mở và đóng phần thân hàm và bỏ cả từ khóa return. Trong C#, ta gọi cú pháp này là biểu thức Lambda (Lambda expression). Vì trưởng nhóm phát triển TypeScript là cha đẻ của C# nên ta sẽ thấy nhiều tính năng của C# được mang qua TypeScript.

Truyền hàm vào trong hàm

Tham số của hàm không chỉ bị giới hạn bởi những kiểu thường gặp như string, number hay boolean, ta còn có thể truyền một hàm vào một hàm khác. Nghe có vẻ lạ nhưng ý tưởng này đã có từ lâu và được sử dụng trong nhiều ngôn ngữ như C++ (con trỏ hàm) hoặc C# (delegate).

1 var checkName = function(fn: (first: string, last: string) => string) {
2     var name = fn("Joe", "Doe");
3     ...
4 }
5 
6 checkName(getFullName);

Tại dòng 1, ta thấy hàm checkName() nhận duy nhất một tham số fn, và tham số này là một hàm khác có hai tham số kiểu string và trả về kiểu string:

1 fn: (first: string, last: string) => string

Đây được gọi là chữ ký hàm (function signature) trong TypeScript. Trong phần thân hàm checkName(), ta gọi chạy hàm được gán vào tham số fn. Sau cùng, ở dòng 6, ta gọi hàm checkName() và truyền vào tham số là hàm getFullName() vì hàm này có chữ ký tương ứng với hàm fn.

Quá tải hàm (Overload)

Overload là một khái niệm trong các ngôn ngữ lập trình hướng đối tượng. Overload giúp ta tái sử dụng tên hàm nhưng với các kiểu tham số khác nhau. Tuy nhiên, cách thức để overload hàm trong TypeScript hơi khác:

1 function add(number1: number, number2: number): number;
2 function add(number1: string, number2: number): number;
3 function add(number1: number, number2: string): number;
4 function add(number1: string, number2: string): number;
5 
6 function add(number1, number2) {
7     return number1 + number2;
8 }

Dòng 1 đến dòng 4 là danh sách khai báo các overload. Mục đích chính khi khai báo danh sách này là để giới hạn các kiểu tham số có thể truyền vào hàm. Định nghĩa hàm add() ở dòng 6 không khai báo kiểu cho các tham số, điều này nghĩa là tham số sẽ có kiểu any. Với kiểu any, ta có thể truyền vào trong hàm bất kì kiểu gì. Điều này rất nguy hiểm vì nếu đưa vào tham số có kiểu không phù hợp, ứng dụng sẽ gây ra lỗi. Một lợi ích khác khi dùng danh sách khai báo overload là Visual Studio sẽ dùng nó để gợi ý code. Nếu không có danh sách trên, khi gọi hàm add(), một tooltip hiện ra bên dưới con trỏ soạn thảo hiển thị chữ ký của hàm add() như sau:

1 add(number1: any, number2: any): any

Gợi ý này không hữu dụng lắm vì ta không biết các kiểu tham số phù hợp để đưa vào hàm. Bây giờ ta thêm vào danh sách như lúc đầu và gõ lại dòng lệnh gọi hàm add(). Tooltip hiện thông tin cho biết có tới 4 phiên bản khác nhau và hiển thị bên cạnh là chữ ký hàm tương ứng với danh sách ta đã khai báo. Ta có thể dùng mũi tên lên xuống để lướt qua các chữ ký hàm trong danh sách. Rõ ràng những thông tin này hữu ích hơn là chỉ thấy một kiểu any từ đầu tới cuối.

Đối tượng và Interface

Trước khi đi vào chủ đề đối tượng trong TypeScript, ta hãy điểm qua các phương pháp tạo đối tượng trong JavaScript để có cái nhìn tổng quan về vấn đề này. Cách thức để tạo một đối tượng trong JavaScript rất lạ so với những ngôn ngữ khác. Thông thường, ở những ngôn ngữ có kiểu tĩnh, ta phải tạo đối tượng thông qua một class. Tuy nhiên, JavaScript không có khái niệm class. Do đó để tạo đối tượng, ta phải dùng những cú pháp đặc biệt. Ta có 3 cách để tạo một đối tượng. Cách thứ nhất là dùng đối tượng Object:

1 var student = new Object();
2 student.name = "Joe";
3 student.age = 20;
4 
5 var teacher = new Object();
6 teacher.name = "Hieu";
7 teacher.age = 30;

Trong JavaScript, mọi thứ đều kế thừa từ Object. Mọi đối tượng ta tạo đều thừa hưởng method và property của Object. Cách này ít được dùng vì phải gõ nhiều từ khóa và có những phần lặp lại không cần thiết. Do đó, ta có cách định nghĩa thứ hai:

1 var student = {
2     name: "Joe",
3     age: 20
4 };
5 
6 var teacher = {
7     name: "Hieu",
8     age: 30
9 };

Đây là cách thông dụng nhất hiện nay. Đặc biệt, cú pháp này cũng được sử dụng cho định dạng JSON (JavaScript Object Notation). Cách này tuy gọn nhưng bất tiện, vì ta phải lặp lại cú pháp mỗi khi tạo đối tượng mới. Những đối tượng chứa các phần tử giống nhau thì chúng có cùng interface. Trong ví dụ trên, ta có đối tượng studentteacher có cùng interface.

Cuối cùng, ta có thêm một cách thứ ba thuận tiện hơn:

1 function person(name, age) {
2     this.name = name;
3     this.age = age;
4 }
5 
6 var student = new person("Joe", 20);
7 var teacher = new person("Hieu", 30);

Với cách này, ta có thể tạo bao nhiêu đối tượng tùy thích theo khuôn mẫu đã khai báo trong hàm person() và hàm này gọi là hàm khởi tạo (constructor). Phương pháp này rất thuận tiện nếu tạo nhiều đối tượng có cùng interface.

JavaScript còn cho phép ta chỉnh sửa interface của đối tượng tùy ý. Giả sử tôi có đoạn code sau:

1 var student = {
2     name: "Joe",
3     age: 20
4 };
5 
6 student.greet = function() {
7     return "Hi there";
8 };

Ở dòng 6, tôi sửa interface của đối tượng student bằng cách thêm vào một hàm tên greet(). Trong các ngôn ngữ kiểu tĩnh, đối tượng sau khi đã tạo thì không thể sửa interface. Ta gọi tính chất này là Encapsulation. Đây cũng là một trong 4 trụ cột của lập trình hướng đối tượng (3 trụ cột còn lại là Abstraction, InheritancePolymorphism). Xâm nhập dễ dàng vào đối tượng là vấn đề gây nhức nhối vì có nhiều rủi ro tiềm ẩn. Đối tượng có thể không còn như lúc đầu vì chúng đã bị thay đổi cấu trúc bên trong.

Kiểu động của JavaScript cũng thường gây ra vài rắc rối khi truyền tham số vào hàm:

1 var student = {
2     name: "Joe",
3     age: 20,
4     greet: function() { return "Hi there"; }
5 };
6 
7 var helloStudent = function(studentObj) {
8     return studentObj.greet();
9 };

Ở dòng 7, ta thấy hàm helloStudent() nhận một tham số là đối tượng student. Tuy nhiên, chẳng có gì ràng buộc điều đó. Tôi có thể truyền vào một đối tượng hoàn toàn khác. Điều này phá vỡ tính chặt chẽ của code. Để khắc phục nhược điểm chết người của JavaScript, TypeScript có một cách để giới hạn kiểu đối tượng được truyền vào hàm. Tôi chỉnh lại code như sau:

1 var helloStudent = function(studentObj: {name: string; age: number; greet: () => string }) {
2     return studentObj.greet();
3 };

Với cách này, nếu truyền vào helloStudent() một đối tượng có interface không phù hợp với cái đã khai báo trong chữ ký hàm thì trình biên dịch TypeScript sẽ báo lỗi. Sử dụng kiến thức của phần trước, tôi thu gọn chúng thành một dòng:

1 var helloStudent = (studentObj: { name: string; age: number; greet: () => string }) => studentObj.greet();

Như bạn thấy, cú pháp TypeScript trở nên vắn tắt, chỉ giữ lại thông tin cần thiết.

Chưa hết, ta còn rút gọn cú pháp này được nữa. Hãy nhìn lại cú pháp dùng để khai báo kiểu đối tượng truyền vào hàm helloStudent(). Nó quá dài, chưa kể trường hợp có nhiều tham số. TypeScript còn một cách khác để giải quyết vấn đề khó coi này, đó là mang interface của studentObj ra đứng riêng một mình:

1 interface IStudent {
2     name: string;
3     age: number;
4     greet: () => string;
5 }
6 
7 var helloStudent = (studentObj: IStudent) => studentObj.greet();

Ta dùng từ khóa interface để định nghĩa một interface mới. Sau từ khóa là tên interface. Để tránh nhầm lẫn, ta thêm chữ I vào phía trước để thông báo đây là một interface chứ không phải một đối tượng hay class (sẽ trình bày ở phần sau). Ở phần tham số studentObj của hàm helloStudent(), ta khai báo tên của interface IStudent. Cú pháp này gọn và đẹp hơn nhiều so với lúc đầu.

Sử dụng Class

Class là một khái niệm không thể thiếu trong bất kì ngôn ngữ lập trình hướng đối tượng nào. Tuy nhiên, với JavaScript, một ngôn ngữ mà tất cả mọi thứ đều là đối tượng, lại không dùng class. Trong TypeScript, mặc dù có thể tạo đối tượng như JavaScript, ta còn được cung cấp thêm khái niệm class quen thuộc.

Cú pháp để tạo một class trong TypeScript khá giống với những ngôn ngữ có hỗ trợ class, đặc biệt là C#:

 1 class Student {
 2     name: string;
 3     age: number;
 4 
 5     greet() {
 6         return "Hi, I am " + this.name;
 7     }
 8 }
 9 
10 var person = new Student();
11 person.name = "Joe";
12 person.age = 20;
13 
14 console.log(person.greet());

Trong đó, nameage là 2 property trong class, còn greet() là method (hàm nằm trong class gọi là method). Tại dòng số 10, ta tạo một đối tượng Student và gán vào biến person. Tiếp theo, ta lần lượt điền thông tin vào cho các property trong đối tượng Student vừa tạo. Cuối cùng, ta gọi chạy greet() của Student và hiển thị giá trị trả về vào cửa sổ console bằng console.log(). Tuy nhiên, mỗi lần tạo đối tượng phải gõ tới 3 dòng (dòng 10 tới 12) thì hơi bất tiện, nếu như có thể thực hiện chỉ trên một dòng thôi thì quá tuyệt. Ta có thể thực hiện điều này thông qua constructor. Tôi sẽ viết lại đoạn code trên như sau:

 1 class Student {
 2     name: string;
 3     age: number;
 4 
 5     constructor(name: string, age: number) {
 6         this.name = name;
 7         this.age = age;
 8     }
 9 
10     greet() {
11         return "Hi, I am " + this.name;
12     }
13 }
14 
15 var person = new Student("Joe", 20);
16 
17 console.log(person.greet());

Tại dòng số 5, ta thêm một constructor(). Đây mà method đặc biệt sẽ tự động chạy mỗi khi dùng toán tử new để tạo đối tượng mới. Constructor này nhận 2 tham số tương ứng với 2 property trong class. Trong phần thân, ta gán giá trị của tham số vào property. Ở dòng 15, ta đưa giá trị cần gán cho đối tượng vào constructor. Phiên bản này thuận tiện và ngắn gọn hơn, tuy nhiên, ta cũng có thể thu gọn hơn nữa:

 1 class Student {
 2 
 3     constructor(public name: string, public age: number) {
 4     }
 5 
 6     greet() {
 7         return "Hi, I am " + this.name;
 8     }
 9 }
10 
11 var person = new Student("Joe", 20);
12 
13 console.log(person.greet());

Ở dòng số 3, ta thêm từ khóa public vào trước các tham số của constructor(). Với thao tác này, ta có thể bỏ đi phần khai báo property và bỏ luôn cả thao tác gán giá trị cho chúng trong phần thân của constructor. Tuy cú pháp ở phiên bản này khác trước nhưng JavaScript sinh ra hoàn toàn giống nhau. Đây chỉ là đường tắt giúp tiết kiệm thời gian.

Nhớ lại trong JavaScript, một đối tượng khi được tạo có thể được chỉnh sửa tùy ý. Điều này rất nguy hiểm vì có thể do vô tình, ta thay đổi một giá trị quan trọng và làm hỏng cả đối tượng (ví dụ như gán một số âm cho age). Để khắc phục nhược điểm này của JavaScript, TypeScript cung cấp tính năng mới gọi là accessor. Gọi là mới thì không chính xác lắm vì C# đã sử dụng tính năng này từ lâu và giờ được mang qua TypeScript. Accessor bao gồm 2 phần: một phần gọi là getter có nhiệm vụ trả về giá trị của property cần lấy, phần còn lại gọi là setter có nhiệm vụ gán giá trị mới vào property. Vậy là ta đã có 2 người gác cổng, setter đảm nhận cổng vào, getter canh giữ cổng ra. Mọi giá trị không phù hợp đều sẽ bị xử lý bởi hai gã gác cổng này.

 1 class Student {
 2 
 3     get Age() {
 4         return this.age;
 5     }
 6 
 7     set Age(age: number) {
 8         if (age > 0)
 9             this.age = age;
10         else
11             this.age = Math.abs(age);
12     }
13 
14     constructor(public name: string, private age: number) {
15         this.Age = age;
16     }
17 
18     greet() {
19         return "Hi, I am " + this.name;
20     }
21 }
22 
23 var person = new Student("Joe", -20);
24 console.log(person.Age);

Từ dòng 3 đến dòng 12 là các accessor. Ta dùng từ khóa get và set để phân biệt người gác cổng đầu ra và đầu vào. Trong phần get, ta chỉ đơn giản trả về giá trị của age. Nhưng trong phần set, ta kiểm tra giá trị đầu vào nhằm ràng buộc chúng theo những điều kiện phù hợp. Ở đây, khi giá trị đưa vào là số âm, tôi sẽ lấy giá trị tuyệt đối của nó. Ta cũng có thể bỏ đi phần set và chỉ để lại phần get, lúc này thì property sẽ chỉ đọc (read-only). Tại dòng 14, tôi sử dụng private thay vì public cho tham số age. Như vậy, ta không thể truy cập age từ bên ngoài. Thay vào đó, mọi truy cập phải thông qua accessor. Cuối cùng, để thử, tôi gán giá trị số âm cho age tại dòng 23. Vì nhờ sự kiểm duyệt của setter, giá trị -20 biến thành 20 khi gán vào cho age.

Trong phần trước, tôi có đề cập đến vấn đề quá tải hàm (overload). Trong class, ta cũng có thể thực hiện overload cho các method. Cách thức cũng tương tự như với hàm, tuy nhiên, vì đây là method nên ta sẽ bỏ đi từ khóa function trong danh sách chữ ký. Để minh họa, tôi sẽ overload greet() trong class Student:

 1 ...
 2 greet(): string;
 3 greet(name: string): string;
 4 greet(student: Student): string;
 5 
 6 greet(value?: any) {
 7     if (typeof value === "string") {
 8         return "Hi, " + value;
 9     } else if (value instanceof Student) {
10         return "Hi, " + value.name;
11     }
12     return "Hi, I am " + this.name;
13 }
14 ...

Dòng 2 đến 4 là danh sách các chữ ký. Danh sách này giúp xác định các giá trị tham số có thể truyền vào trong greet(). Tại dòng số 6, ta bắt đầu định nghĩa cho greet(). Trong phần định nghĩa, tham số bắt buộc phải có kiểu any. Ngoài ra, tôi dùng dấu ? ngay phía sau tên của nó để ra hiệu đây là tham số không bắt buộc. Trong phần thân, tôi lần lượt kiểm tra các kiểu của tham số và trả về giá trị cần thiết tùy theo từng trường hợp. Từ khóa typeofinstanceof rất quen thuộc trong JavaScript nhằm để kiểm tra kiểu của biến.

Khi ta thực hiện viết code cho dòng số 10, sau khi gõ value và dấu chấm phía sau, tính năng nhắc mã IntelliSense của Visual Studio không hoạt động bởi vì value có kiểu any và Visual Studio không biết đây là kiểu gì nên không thể gợi ý mã được. Do đó, để giúp IntelliSense có thể hiểu được đây là kiểu Student, ta thực hiện chuyển kiểu dữ liệu (casting). Cú pháp casting hơi khác so với các ngôn ngữ như C++ hay C#, thay vì dùng cặp ngoặc đơn (), ta sẽ dùng cặp dấu <>.

 1 ...
 2 greet(value?: any) {
 3     if (typeof value === "string") {
 4         return "Hi, " + value;
 5     } else if (value instanceof Student) {
 6         var student = <Student>value;
 7         return "Hi, " + student.name;
 8     }
 9 
10     return "Hi, I am " + this.name;
11 }
12 ...

Tại dòng 6, ta tiến hành casting để chuyển giá trị value sang Student. Và ở dòng 7, sau khi gõ student và dấu chấm, IntelliSense hiện ra danh sách các property và method có trong class Student.

Ngoài ra, ta cũng có thể overload cho constructor() bằng danh sách chữ ký tương tự như ta đã làm cho greet(). Nếu như bạn đã có sẵn kiến thức về lập trình hướng đối tượng thì phần này tương đối nhẹ nhàng. Trong phần tiếp theo, ta sẽ khám phá một trụ cột trong lập trình hướng đối tượng, đó là tính kế thừa (Inheritance).

Kế thừa

Một trong những phần phức tạp trong JavaScript là kế thừa (Inheritance). Ngay cả khi đã hiểu rõ khái niệm này trong các ngôn ngữ như C# hay C++, bạn cũng sẽ bỡ ngỡ khi gặp JavaScript. Kế thừa trong JavaScript không giống với bất kì ngôn ngữ hướng đối tượng nào vì nó xoay quanh khái niệm prototype (prototypal inheritance).

Nhưng thật may mắn, chúng ta đã có TypeScript. Microsoft thấu hiểu nỗi đau của lập trình viên khi làm việc với JavaScript nên họ đã thiết kế TypeScript sao cho dễ dùng nhất. Nhờ vậy, ta không còn phải vật lộn với một đống cú pháp dài ngoằng, rối rắm của JavaScript. Để bắt đầu trình bày tính kế thừa trong TypeScript, tôi đã chuẩn bị một class như sau:

 1 class Girl {
 2     constructor(public name: string, public age: number) {}
 3 
 4     greet(): string;
 5     greet(name: string): string;
 6     greet(girl: Girl): string;
 7     greet(value?: any) {
 8         if (typeof value === "string") {
 9             return "Hi, " + value;
10         } else if (value instanceof Girl) {
11             var girl = <Girl>value;
12             return "Hi, " + girl.name;
13         }
14         return "Hi, I am " + this.name;
15     }
16 }

Nội dung class Girl này rất đơn giản. Bây giờ, tôi sẽ tạo một class mới để kế thừa class Girl.

 1 class HotGirl extends Girl {
 2     constructor(name: string, age: number, public isLongLeg: boolean) {
 3         super(name, age);
 4     }
 5 
 6     greet(): string;
 7     greet(name: string): string;
 8     greet(girl: Girl): string;
 9     greet(hotGirl: HotGirl): string;
10     greet(value?: any) {
11         if (value instanceof HotGirl) {
12             return "Hi, " + (<HotGirl>value).name +
13                     ". My name is " + this.name +
14                     " and I'm a hot girl.";
15         }
16         return super.greet(value);
17     }
18 }

Để khai báo quan hệ kế thừa giữa hai class, ta dùng từ khóa extends. Ở đây, class HotGirl sẽ kế thừa toàn bộ property và method của class Girl. Trong quan hệ kế thừa, ta gọi class Girl là class cha, còn class HotGirl là class con. Đặc biệt, trong constructor của class HotGirl, ta buộc phải gọi lại constructor của class Girl (class cha) bằng từ khóa super. Từ khóa super này tượng trưng cho class Girl. Mỗi khi ta cần gọi method nào ở class Girl trong class HotGirl, ta sẽ dùng từ khóa super. Danh sách tham số của constructor trong class HotGirl bao gồm các tham số có trong constructor của class Girl, tuy nhiên tham số isLongLeg chỉ dành riêng cho HotGirl.

Trong phần thân, ta truyền 2 tham số nameage vào lại trong constructor của class Girl để nó gán giá trị vào property nameage. Lưu ý là ta không dùng từ khóa public trong constructor của class con vì các property đã được kế thừa từ class cha. Còn trường hợp tham số isLongLeg có từ khóa public là vì nó là property mới, dành riêng cho class HotGirl và không tồn tại trong class Girl.

Tiếp theo, tôi sẽ tiến hành test class HotGirl với vài câu lệnh đơn giản và xuất giá trị ra cửa sổ console của trình duyệt.

1 var hotGirl = new HotGirl("Nga", 24, false);
2 var girl = new Girl("Ngoc", 25);
3 var anotherHotGirl = new HotGirl("Hong", 23, true);
4 
5 console.info(hotGirl.greet());
6 console.info(hotGirl.greet("Huong"));
7 console.info(hotGirl.greet(girl));
8 console.info(hotGirl.greet(anotherHotGirl));

Sau khi chạy đoạn code trên, bạn nhấn F12 để mở console của trình duyệt và kiểm tra kết quả.

Lời kết

Chúng ta đã tới hồi kết của bài viết này. Bạn nên nhớ là TypeScript vẫn đang trong quá trình phát triển. Mọi thứ có thể sẽ thay đổi trong tương lai. Trong quá trình nguyên cứu TypeScript, tôi chỉ thấy cái khó của nó nằm ở cú pháp mới lạ. Còn về phần tính năng, khái niệm thì khá quen thuộc.

Những gì tôi trình bày trong bài này chỉ là những tính năng thường dùng và chúng chiếm một phần nhỏ trong TypeScript. Do vậy, bạn nên tham khảo những thông tin trên trang TypeScript để cập nhật những thay đổi mới nhất, đồng thời nghiên cứu thêm những tính năng cao cấp.