[TS] Ghi chú dành cho TypeScript từ một Javascript developer

[TS] Ghi chú dành cho TypeScript từ một Javascript developer

20-11-2021

Bài này dành để ghi chú một số thứ mình thấy đáng lưu ý khi học và sử dụng TypeScript. Đây không phải là tutorial hướng dẫn sử dụng TS, bởi vậy bài này dành cho những bạn đã sử dụng TS và biết một số khái niệm thông dụng.

0. Các kiểu cơ bản

Trong TS, ta có các type cơ bản là:

  1. number
  2. string
  3. boolean
  4. null
  5. undefined
  6. unknown
  7. any
  8. never

5 type đầu, có lẽ ai cũng biết, chỉ có 3 loại sau mà chúng ta cần lưu ý là: unknown, neverany.

  • unknown: Đây là một type đặc biệt. Nếu xét về khía cạnh toán học, đây là type bao hàm tất cả các type còn lại trong danh sách trên. Nếu chúng ta khai báo một biến với type unknown, nó có thể nhận bất kỳ value của bất cứ type nào. Nếu xét về ngữ nghĩa khi sử dụng, chúng ta sẽ dùng nó cho những biến có type chưa xác định tại thời điểm viết. Ví dụ như error khi xử lý gọi request, nếu chúng ta muốn dùng nó thì cần phải thu hẹp nó về một type xác định trước khi dùng.

class ServerError {
code: string;
// constructor(...) {...}
}
try {
// thực hiện một request
} catch (e: unknown) {
console.log(e.code); // Lỗi vì type của e chưa xác định, cần thu hẹp
if (e instanceof ServerError) {
console.log(e.code); // Hợp lệ, lúc này e đã thu hẹp về một type xác định là ServerError
}
}

  • never: Đây cũng là một kiểu đặc biệt. Cũng xét về khía cạnh toán học, đây là type nhỏ nhất, với một value type never, nó có thể được gán cho bất kỳ biến có type nào. Xét về khía cạnh ngữ nghĩa, type này ít khi được dùng trực tiếp, mà dùng để hệ thống bắt lỗi hoặc sử dụng trong generics.

let a: never = 'lorem';
let b: number;
b = a; // hợp lệ
a = b; // không hợp lệ

  • any: Đây là một type đặc biệt mũ hai của Typescript. Nó là sự kết hợp của unknown và never. Tức là nếu một biến được khai báo với kiểu any, tức là biến đó có thể chứa bất kỳ giá trị nào, đồng thời, biến với kiểu any cũng có thể gán cho những kiểu dữ liệu khác. Kiểu này thường dùng cho những trường hợp chúng ta không biết đặt type thế nào cho biến, hoặc cũng không quan trọng type nữa (vì quá phức tạp và không cần thiết), đặc biệt dùng nhiều nếu migrate project từ JS sang TS.

let a: any = 'lorem';
let b: number;
b = a; // hợp lệ
a = b; // hợp lệ

1. Literal type

Ngoài các types đã kể phía trên, có một type đặc biệt, được gọi là literal type.


// 1
let num1 = 1;
// 2
const num2 = 1;

Đối với khai báo trường hợp số 1, kiểu của num1 sẽ là number, bởi vì chúng ta đang khai báo với let, điều này dẫn đến num1 có thể được gán với một giá trị khác cùng kiểu.

Trong khi với trường hợp số 2, kiểu của num2 sẽ là 1. Do được khai báo với const nên chắc chắn giá trị của num2 chỉ được giữ một giá trị duy nhất.

Vậy điều này có ý nghĩa gì? Hãy cùng xem ví dụ bên dưới:


function makeRequest(type = 'POST' | 'GET', data: any) {
//... execute request
}
const request = {
method: 'POST',
data: { id: 1 },
};
/// Liệu rằng dòng dưới có hợp lệ hay không?
makeRequest(request.method, request.data);

Hàm makeRequest nhận 2 tham số, tham số đầu tiên là type, với kiểu chính là literal type: "POST" hoặc là "GET".

Đối với object request, chúng ta sẽ có 2 thuộc tính method và data. Tuy nhiên, dù khai báo với const, nhưng request.method sẽ mang kiểu là string bởi vì chúng ta vẫn có thể sửa nó. Ví dụ request.method = "abc".

Bởi vậy, việc gọi hàm makeRequest(request.method, request.data) sẽ báo lỗi, vì kiểu sẽ không được chấp nhận.

Tuy nhiên, nếu chúng ta chắc chắn rằng mình sẽ không bao giờ thay đổi giá trị của object request, chúng ta có thể làm như sau:


// Sử dụng từ khóa as const
const request = {
method: 'POST',
data: { id: 1 },
} as const;
makeRequest(request.method, request.data);

Khi sử dụng const sau một giá trị, tức ta đã biến giá trị đó thành literal type.

2. Function type

Đối với việc khai báo kiểu cho hàm, chúng ta có rất nhiều cách:


// CÁCH 1
function sum(a: number, b: numer): number {
return a + b;
}
// CÁCH 2
const sum = (a: number, b: number): number => {
return a + b;
};
// CÁCH 3
type SumFn = (a: number, b: number) => number;
// -- hoặc
type SumFn = {
(a: number, b: number): number;
};
// -- hoặc
interface SumFn {
(a: number, b: number): number;
}
const sum: SumFn = (a, b) => {
return a + b;
};
// HÀM KHÔNG TRẢ VỀ
function log(a: number): void {
console.log(a);
}
// Typing cho this
function clickHandler(this: HTMLButtonElement, event: Event) {
// ...
}

Vì hàm cũng là object, nên đôi khi chúng ta sẽ có trường hợp cần thêm thuộc tính cho hàm:


type MathFn = {
(a: number, b: number): number;
operator: string;
};
const sum: MathFn = (a, b) => a + b;
sum.operator = '+';

Sử dụng Generics với hàm:


function arrayify2<Type>(a: Type): Array<Type> {
return [a];
}
// hoặc
const arrayify = <Type extends unknown>(a: Type): Array<Type> => [a];

Đối với trường hợp Generics với arrow function, chúng ta phải sử dụng extends. Nguyên nhân có thể tham khảo tại đây.

Đôi khi một hàm có thể có nhiều hình thái (tức là tham số truyền vào có thể khác nhau về kiểu hoặc số lượng, kết quả trả về có thể khác kiểu). Người ta gọi đó là overload.

Cho một ví dụ như sau, chúng ta sẽ hiện thực một hàm calc, nhận vào 3 tham số: op, a và b. op chỉ có thể là + hoặc -. Nếu + thì a, b phải cùng là string hoặc cùng là number, và type trả về cũng tương ứng. Nhưng nếu op là -, thì a và b chỉ có thể là number


function calc(op: '+', a: string, b: string): string;
function calc(op: '+', a: number, b: number): number;
function calc(op: '-', a: number, b: number): number;
function calc(
op: '+' | '-',
a: number | string,
b: number | string
): number | string {
if (op === '-' && typeof a === 'number' && typeof b === 'number') {
return a - b;
}
if (op === '+' && typeof a === 'number' && typeof b === 'number') {
return a + b;
}
if (op === '+' && typeof a === 'string' && typeof b === 'string') {
return a + b;
}
throw new Error('a, b should be the same type');
}
calc('-', 1, 2);
calc('-', '1', '2'); // error

Bạn có thể thử lại đây

Một số ví dụ trên được tham khảo từ kentcdodds blog

3. Union và Intersection types

3.1 Union


let a: { b: string } | { c: string };

Ví dụ trên thể hiện union types. Biến a có kiểu là object và cấu trúc có thể linh hoạt 1 trong 2.

Hoặc là a: { b: string } hoặc là a: { c: string }.

Có 3 trường hợp đặc biệt:


let a: string | number | never;
// Sẽ trở thành
let a: string | number;
--
let b: string | number | any;
// Sẽ trở thành
let b: any;
--
let c: string | number | unknown;
// Sẽ trở thành
let c: unknown;

Bạn có nhận ra vì sao như vậy không?

anyunknown đại diện cho type lớn nhất bao hàm mọi type, và never thì type nhỏ nhất mà type nào cũng bao hàm.

3.2 Intersection types


let a: { b: string } & { c: string };

Intersection types thì khác union, nó gộp các type lại với nhau, nhưng phải đảm bảo việc gộp hợp lý. Như ví dụ trên, ta sẽ có biến a có kiểu là { b: string; c: string }.

Tuy nhiên, nếu ta khai báo như này:


let a: number & string;

Trong trường hợp này, không có giá trị nào thỏa mãn gộp 2 kiểu đó cả, nên a sẽ có type never.

Thêm vài ví dụ để dễ hiểu:


let a: { b: string } & { b: number; c: string };
let b: {
d: {
a: string;
};
} & {
d: {
c: string;
};
};

Kết quả sẽ là:


let a: { b: never; c: string };
let b: {
d: {
a: string;
c: string;
};
};

3.3 Kết hợp

Sẽ nếu ra sao nếu kết hợp union và intersection?


type A = { a: string } | { a: number };
type B = { b: string };
type C = A & B; // ===> ???

Ta có thể liên tưởng như thế này:


A = A1 + A2;
B;
C = (A1 + A2) * B;
C = A1 * B + A2 * B;

Tất nhiên đó là sự liên tưởng, nhưng khi kết hợp & và | thì cách hoạt động cũng tương tự như vậy, nên ta có


type C = ({ a: string } & { b: string }) | ({ a: number } & { b: string });
// ==>
type C = { a: string; b: string } | { a: number; b: string };
// ==>
type C = {
a: string | number;
b: string;
};

Vậy với ví dụ bên dưới thì sao?


type A = { a: string } | { b: number };
type B = { c: string } | { d: string };
type C = A & B; // ===> ???

4. Interfaces vs Type aliases

Interface với type alias có thể sử dụng thay thế cho nhau trong hầu hết các trường hợp.

Chức năng của chúng dùng để định nghĩa các custom type.

Trong phần này mình chỉ liệt kê sự khác nhau giữa interface và type (Câu này rất được hay hỏi trong phỏng vấn 😁).

4.1 Một số điểm chung cần lưu ý

  • Type có thể extend một type, interface khác sử dụng intersection (&)
  • Interface có thể extend một interface, type khác sử dụng extends
  • Class có thể implements type và interface

4.2 Interface chỉ định nghĩa object type

Trong khi interface chỉ định nghĩa các object type, type alias có thể linh động hơn (primitive types, unions, tupples). Ví dụ:


type Request = 'POST' | 'GET';

4.3 Khác nhau về sự khai báo trùng lặp

Trong cùng 1 scope, với 1 tên, type alias chỉ có thể khai báo một lần. Trong khi đó, interface có thể khai báo nhiều lần, và kết quả sẽ là gộp giữa các interface đó với nhau. Điều này thật sự có ích khi chúng ta tạo ra một thư viện, người dùng có thể sử dụng interface để ghi đè hoặc mở rộng type sẵn có.

5. Type guards và narrowing


function f(a: number | string) {}

Trong ví dụ trên, a có thể là number hoặc string, nhưng khi ta xử lý trên a, trước tiên phải xác định kiểu của a. Việc thu hẹp để xác định kiểu của a trong trường hợp này được gọi là narrowing.

Chúng ta có thể sử dụng các từ khóa có sẵn của TS (JS) để thực hiện narrowing, như là: typeof, instanceof (được gọi là các type guards).


function f(a: number | string) {
if (typeof a === 'number') {
// Bây giờ type của a là number
// Không còn là string nữa
}
}

Tuy nhiên, chúng ta có thể tự build type guard của mình bằng cách sử dụng từ khóa is.


type A = { a: string };
type B = { b: string };
function f(p: A | B) {
// Làm sao để TS
console.log(p.a); // error
}

Chúng ta phải narrow cho nó trước khi dùng.


type A = { a: string };
type B = { b: string };
function isA(value: any): value is A {
if (typeof value === 'object' && 'a' in value) {
return true;
}
return false;
}
function f(p: A | B) {
if (isA(p)) {
console.log(p.a); // it works
}
}

6. Nullish values

Xem xét trường hợp dưới đây:


type A = {
a?: {
num: number;
};
};
function foo(): number {
const v: A = {
a: {
num: 1,
},
};
return v.a.num + 1; // error
}

Trong ví dụ trên, TS sẽ báo lỗi. Vì v.a có thể undefined (theo type A, a đã được khai báo là optional). Nhưng chúng ta biết chắc là v.a có giá trị và không thể nào undefined.

Chúng ta sẽ dùng toán tử !. để nói với TS rằng mình chắc chắn chuyện đó.


type A = {
a?: {
num: number;
};
};
function foo(): number {
const v: A = {
a: {
num: 1,
},
};
return v.a!.num + 1;
}

7. keyof, typeof

7.1 keyof

  • keyof: Sử dụng khi muốn lấy type của các keys của một type dạng object. Ví dụ:

type Obj = {
1: string;
b: string;
c: number;
};
type Keys = keyof Obj;
// Keys = 1 | 'b' | 'c'

Các type keys sau khi sử dụng keyof có thể là number, string, symbol.

Trong ví dụ trên, nếu chúng ta chỉ muốn lấy những keys thuộc kiểu string, thì có thể sử dụng intersection (&):


type Obj = {
1: string;
b: string;
c: number;
};
type Keys = keyof Obj;
// Keys = 1 | 'b' | 'c'
type StringKeys = Keys & string;
// Keys = 'b' | 'c'

Tại sao Keys & string lại chỉ trả về các type dạng string?


type StringKeys = Keys & string;
// tương đương
type StringKeys = (1 | 'b' | 'c') & string;
// tương đương
type StringKeys = (1 & string) | ('b' & string) | ('c' & string);
// tương đương
type StringKeys = never | 'b' | 'c';
// tương đương
type StringKeys = 'b' | 'c';

Trong đoạn code trên, có 2 điều cần chú ý:

  • Kết quả trả về khi sử dụng intersection: "b" & string => "b"
  • Kết quả trả về khi sử dụng union với never:
    • Bất cứ type nào union với never cũng trả về chính type đó: string | never => string
  • Từ 2 quy luật trên, có thể rút ra quy luật chung như thế này:
    • Khi union ta sẽ có kết quả là type rộng hơn
    • Khi intersection ta sẽ có kết quả là type hẹp hơn

Câu hỏi dành cho bạn:


type A = unknown | string; // ???
type A = unknown & string; // ???

7.2 typeof

  • typeof dùng để lấy type của một giá trị.

const a = {
b: 1,
c: 'Hello world',
};
type A = typeof a;
// Tương đương
type A = {
b: number;
c: string;
};

7.3 Lưu ý và ví dụ thực tế

  • Lưu ý:
    • keyof sử dụng với type
    • typeof sử dụng với giá trị (value)
  • Ví dụ thực tế: Trong một dự án React có sử dụng i18n, ta có rất nhiều file translation: en.ts, vi.ts,... Vấn đề cần giải quyết đó là làm sao đảm bảo các key trong file en và file vi phải giống nhau, không thể để một bên có và một bên không, gây ra lỗi khi hiển thị.

// file en.ts
const translations = {
"Hello": "Hello",
"World: "World"
}
export translations


// file vi.ts
const translations = {
"Hello": "Xin chào",
"World: "Thế giới"
}
export translations

Ta có thể nhận thấy giữa 2 file không có ràng buộc gì với nhau.

Đây là lúc chúng ta vận dụng kiến thức về typeof.


// file en.ts
const translations = {
Hello: 'Hello',
World: 'World',
};
export type TranslationEn = typeof translations;
export default translations;


// file vi.ts
import type { TranslationEn } from './en.ts';
const translations: TranslationEn = {
Hello: 'Xin chào',
World: 'Thế giới',
};
export default translations;

8 Generics

8.1 extends và conditional types

Viết mãi mới đến phần extends 😅.

Chú ý: extends trong Generics khác với extends khi sử dụng để thừa kế với class, interface.

Lấy một ví dụ như thế này. Chúng ta muốn đảm bảo một cái name luôn có last name là T.


type WithLastName<T> = T extends string ? `${string} ${T}` : never;
let name1: WithLastName<'Le Huu'>;
name1 = 'Viet Anh Le Huu'; // hợp lệ
name1 = 'Dao Mai'; // lỗi
let name2: WithLastName<123>;
name2 = 123; // lỗi

Trong ví dụ này ta có 3 thứ để phân tích

  • conditional types
  • extends
  • template literal types

Đầu tiên là với conditional types, đó chỉ là một thuật ngữ, cách hoạt động của nó giống như toán tử ternary A ? B : C trong JS.

Đối với vế A trong conditional types đó, chúng ta đang sử dụng extends. Thường cú pháp sẽ là D extends E, với D là một type được truyền vào type generics. Nghĩa của nó là D có phải là một type con cụ thể của E hay không (type của D có phải tập con của E hay không).

Ví dụ:

3 extends number: Trong vô vàn các giá trị (type) của number, thì 3 chính là một type cụ thể hơn của nó.


type User = {
name: string;
}
T extends User
{ name: string; age: number } extends User // true

Ví dụ này thì có phần khó hiểu hơn, với type User khi sử dụng với extends, nó sẽ được diễn dịch như thế này: Nhận 1 type T với điều kiện tối thiểu là một object type có ít nhất những thuộc tính như thế này: { name: string }. Bởi vậy, { name: string; age: number } sẽ thỏa điều kiện đó.

Xem xét thêm các ví dụ bên dưới (Ví dụ được lấy từ TS course):


1. 64 extends number
2. number extends 64
3. string[] extends any
4. string[] extends any[]
5. never extends any
6. any extends any
7. Date extends { new (...args: any[]): any }
8. typeof Date extends { new (...args: any[]): any }

Hãy thử trả lời trước khi lướt xem đáp án nào :)

.

.

.

.

.

.

.

.

.

.

Đáp án và giải thích:

  1. 64 extends number là true, 64 là một literal type cụ thể hơn của number.

  2. number extends 64 là false, number không thể là type cụ thể hơn của 64, nếu có 1 type T thỏa mãn T extends 64 thì chỉ có thể là type 64.

  3. string[] extends any là true, bất cứ type gì cũng đều xác định và cụ thể type any.

  4. string[] extends any[] là true, tương tự như trên.

  5. never extends any là true, never là type cụ thể nhất trong tất cả các type, đại diện cho không có gì :D.

  6. any extends any là true, any là type vừa khớp với any.

  7. Date extends { new (...args: any[]): any } là false. type Date thể hiện là type của 1 instance của Date, không phải là type của một constructor function.

  8. typeof Date extends { new (...args: any[]): any } là true. typeof Date thể hiện type của một constructor function/class.

Và thứ cuối cùng cần chú ý là template literal types.

8.2 Type inference

Trong quá trình làm việc, mình có một bài toán và được rút gọn như dưới đây:


// file generated-types.ts
type Product = {
name: string;
};
export type Data = Product[] | null;
--
// file component.ts
import { Data } from './generated-types.ts';
const data: Data;
function logProduct(product: any) {}
data?.forEach((product) => logProduct(product));

Vấn đề của bài toán trên cần giải quyết đó là typing (khai báo kiểu) cho tham số product của hàm logProduct. Chúng ta bị một ràng buộc là không thể chỉnh sửa file generated-types.ts, chính vì thế, chúng ta không thể export type Product ra bên ngoài được. (Không thể chỉnh sửa file generated, bởi vì nó được backend sinh ra, và mỗi lần cập nhật sẽ xóa hết nội dung cũ thay bằng nội dung mới, những gì chỉnh sửa trong file đó sẽ mất.)

Phân tích kỹ hơn, bài toán chúng ta cần làm là tách type Product từ type Data.

Solution đầu tiên, đơn giản nhưng không mang tính scale.


type ProductList = Exclude<Data, null>;
type Product = ProductList[number];

Bằng cách này, đầu tiên, sẽ loại bỏ type null trong Data để chỉ còn lại Product[] và gán cho type ProductList. Sau đó, để lấy type của mỗi item trong list, ta sử dụng cú pháp [number]. Như vậy, chúng ta đã lấy được type của Product.

Nhưng tại sao mình lại nhận định đây là một solution không scale. Nếu sau đó phía backend cập nhật type của Data.


type Data = Product[] | string | null;

Phần code chúng ta đã thực hiện Exclude<Data, null> không còn đúng nữa.

Solution thứ 2 sẽ hiệu quả hơn, chúng ta sẽ xem trước và phân tích sau:


type ExtractElementType<T> = T extends (infer U)[] ? U : never;
type Product = ExtractElementType<Data>;

Thoạt đầu nhìn sẽ hơi khó hiểu, nhưng hãy cùng phân tích:

Chúng ta tạo ra một type generics ExtractElementType nhận vào một type T. Type T sẽ được xem xét điều kiện để trả ra type cuối cùng. Điều kiện sẽ là T có phải là một type cụ thể, có dạng là U[] hay không, với U sẽ được TS cố gắng infer(suy luận ra). Nếu đáp ứng điều kiện, kết quả sẽ trả về là type U được infer đó, còn không sẽ trả về never.

Tiếp theo, ta sử dụng nó với Data, các bước nó tạo ra kết quả như sau:


type Product = ExtractElementType<Data>;
// tương đương
type Product = ExtractElementType<Product[] | string | null>;
// tương đương
type Product =
| ExtractElementType<Product[]>
| ExtractElementType<string>
| ExtractElementType<null>;
// tương đương
type Product = Product | never | never;
// tương đương
type Product = Product;

Điểm mấu chốt để hiểu ở đây chính là cách hoạt động của union và kết quả union khi kết hợp với never. Đặc biệt, đó là sự tồn tại của từ khóa infer, TS sẽ cố gắng giúp chúng ta suy luận ra một type nào đó.

9. Indexed Access Types

Như đã gặp trong các ví dụ trên, chúng ta có thể lấy type của một phần tử, thuộc tính trong một type khác bằng cách sử dụng cú pháp indexed access. Tính chất này tương tự như truy cập một thuộc tính của object trong JS.


type A = {
name: 'Viet Anh' | 'Anh Le';
};
type B = [number, string, boolean];
type Name = A['name']; // "Viet Anh" | "Anh Le"
type TuppleItem = B[number]; // number | string | boolean

10. Mapped Types

Chủ yếu của phần này đó là chúng ta có thể lặp qua một type nào đó để tạo ra một type khác.


type A = { [k: string]: string };
type B = { [k in 'name' | 'value']: any };
type CompoentProps = { [k in keyof Window]: Window[k] };

Xem thêm