Skip to content

TypeScript(1) 基本概念

Posted on:2024年8月5日 at 下午09:00
TypeScript(1) 基本概念

TypeScriptJavaScript 的超集:它是 JavaScript 的語法擴充。原則上,所有 JavaScript 語法都能在 TypeScript 中沿用,只是更加嚴格。

TypeScript 中,「型別(type)」被加入程式碼空間中。所謂型別,是對對象的結構的標注與描述。一個具有該型別的對象,擁有該型別所擁有的所有屬性與方法。什麼意思呢?

譬如,一個作為 string(字串)型別的對象,它應當擁有所有 string 的方法和屬性:

let str: string = "Hello, TypeScript!";
// str 的型別被標注為 string。

console.log(str.length); // 19
console.log(str.toUpperCase()); // 'HELLO, TYPESCRIPT!'
console.log(str.toFixed(2));
// 不允許,str 沒有 toFixed 方法。

這裡有個重要的觀念:對於 TypeScript 來說,型別的意義,在於這個對象應該擁有哪些屬性和方法,而不在於它叫什麼名字。我們可以將屬性和方法統稱為「結構」,因此這樣的型別又叫「結構性型別」(或有人會叫它「鴨子型別」)。

如果你想寫 TypeScript,你需要調整 JavaScript 專案的語言配置,這有點麻煩。但你可以在 TypeScript Playground 上進行實驗。

原始型別

有些型別是原始的,包括(下述並非全部,但是最常用的):

// 注意它們都是使用小寫字母。
let str: string = "Hello, TypeScript!";
let num: number = 123;
let float: number = 3.14;
let isTrue: boolean = true;

當變數宣告時,若使用 let 來進行原始型別的變數宣告,TypeScript 會自動推斷出型別:

let str = "Hello, TypeScript!";
// str 的型別是 string
let num = 123;
// num 的型別是 number
let float = 3.14;
// float 的型別是 number
let isTrue = true;
// isTrue 的型別是 boolean

但如果使用 const 來進行原始型別的變數宣告,它會將它視作常數。在這種時候,TypeScript 會將其推斷為字面量型別(literal types):

const str = "Hello, TypeScript!";
// str 的型別是 "Hello, TypeScript!"
const num = 123;
// num 的型別是 123
const float = 3.14;
// float 的型別是 number
const isTrue = true;
// isTrue 的型別是 boolean

要注意的是,在 TypeScript 中,當變數的型別被確定,它便會限制變數的再賦值。這是一個很好的特性,就算你不寫 TypeScript,或許也該自主遵守這個規約:

let str = "Hello, TypeScript!";

str = "Goodbye, TypeScript!";
// 這是可以的,你可以將字串指派為另一個字串。
str = 3;
// 這是不行的,你會看到這樣的錯誤,即便這在 JavaScript 中是合法的:
// TypeError: str is not assignable to type 'string'

有一個邏輯上想當然、但重要的特性,那就是字面量型別,即便不使用 const 宣告,TypeScript 也可能阻止對變量的再賦值:

let str: `Hello, TypeScript!` = "Hello, TypeScript!";
// 使用字面量型別宣告 str。

str = "Goodbye, TypeScript!";
// 這是不允許的,你會看到錯誤:
// Type '"Goodbye, TypeScript!"' is not assignable to type '"Hello, TypeScript!"'.

二元運算子與 never 型別

如果我們真的希望將字串指派為數字,我們可以使用型別的二元運算子。在下面的例子裡面,我們將 strOrNum 的型別宣告為「字串或數字」(這個運算稱作「聯集運算」):

let strOrNum: string | number = "Hello, TypeScript!";

strOrNum = 123;
// 這是可以的。

另一個二元運算子是「&」(稱作「交集運算」)。雖然事實上,這兩個型別是不可能共存的,一個變數不可能既是字串,又是數字:

let strAndNum: string & number;

這時候如何理解 strAndNum 的型別呢?TypeScript 表示「Type 'string & number' is never」,意思是,這會是一個 never 的型別。

沒有任何對象能滿足 never 型別。因此,若有一個事物的型別是 never,代表該對象理論上不該存在。

typeinterface

二元運算子帶來了無數的變化。如果在我們的程式中,多次需要使用某個運算出來的型別,這會是一個麻煩的事情,譬如說,我們希望定義一個稱之為 state 的變數,用來記錄程式當前的狀態:

let state: "idle" | "loading" | "loaded" | "error" = "idle";

如果我們有兩個同樣的型別,一個是 state1,另一個是 state2,我們想要這樣寫,但看起來有些累贅:

let state1: "idle" | "loading" | "loaded" | "error" = "idle";
let state2: "idle" | "loading" | "loaded" | "error" = "idle";

TypeScript 允許我們宣告型別作為獨立的「變數」,這樣寫看起來好多了:

type State = "idle" | "loading" | "loaded" | "error";

let state1: State = "idle";
let state2: State = "idle";

type 用來宣告型別,宣告出來的 State 並非是真正的變數。因此你無法在程式碼中真正使用它。

在心智模式上,你可以把型別當成是獨立於程式的空間,只能用型別的方式表達。

另一種宣告型別的方式是使用 interface,但和 type 略有不同,它只能用來宣告 JS 物件(object)的「形狀」:

interface State {
  type: "idle" | "loading" | "loaded" | "error";
}
// 在一些比較舊的書或是文件裡面,會建議你以 IState 來命名 interface,但這個建議如今已經過時了。

const state: State = {
  type: "idle",
};

事實上,你也能用 type 宣告物件的形狀,這基本上和 interface 是一樣的:

type State = {
  type: "idle" | "loading" | "loaded" | "error";
};

const state: State = {
  type: "idle",
};

這看起來,typeinterface 沒什麼差別,但 type 的功能更多,因為無法用 interface 來宣告原始型別或是原始的字面量型別。這樣我們為何還要使用 interface

interface 的優勢在於幾個特性:

(1) 首先,它的性能要比 type 好一些,一般來說,建議可以的話先考慮使用 interface,只有在必要的時候才使用 type

(2) 此外,它能夠使用 extends 來定義擴展宣告(但它不能使用二元運算子來定義宣告):

interface State {
  type: "idle" | "loading" | "loaded" | "error";
}

interface AppState extends State {
  count: number;
}

const state: AppState = {
  type: "idle",
  count: 0,
};

如果使用 type,可以改用下面這段等價的語法(& 表示將兩個物件的屬性和方法加在一起):

type State = {
  type: "idle" | "loading" | "loaded" | "error";
};

type AppState = State & {
  count: number;
};

const state: AppState = {
  type: "idle",
  count: 0,
};

(3) 最後,interface 允許宣告合併(declaration merging):

interface State {
  type: "idle" | "loading" | "loaded" | "error";
}

interface State {
  count: number;
}

const state: State = {
  type: "idle",
  count: 0,
};

宣告合併這個特性是 type 沒有的,而這個特性廣泛用於第三方模組或是全局型別的擴展上。在這裡可以先記住這個特性,你需要在實作中去看到這特性的好處。

陣列

如今我們討論了原始型別以及物件的型別和介面,現在我們來看看陣列。

陣列在 TypeScript 中有兩種,即便它們在 JavaScript 中毫無區別。一種是多元組(tuple),另一種是真正的陣列(array),差別在於,多元組的長度是固定的,陣列的長度是不定的:

const tuple: [string, string] = ["Hello", " TypeScript"];

const wrongTuple: [string, string] = ["Hello", " TypeScript", " World"];
// 這會出現錯誤,因為宣告的多元組長度是 2。

const array: string[] = ["Hello", " TypeScript"];

tuple 會被 TypeScript 認為是一個長度是 2 的多元組,而 array 則是一個字串陣列。但事實上,TypeScript 並不會頻繁地對陣列進行長度的檢查:

const array: [string, string] = ["Hello", " TypeScript"];

array.push("World");
// TypeScript 允許這個操作,但建議不要進行這樣的操作,這會讓靜態型別檢查失效。

const logArray = (arr: [string, string]): void => {
  console.log(arr.join(" "));
};
// 這是函數的型別宣告,代表它收單一的二元多元組作為參數,並且沒有返回值。

logArray(array);

array 雖然長度變成了 3,但 TypeScript 仍然認為它是一個長度是 2 的多元組。

我們可以把 string[] 更改為其他的型別,來表示更多的陣列型別:

const stringArray: string[] = ["Hello", " TypeScript", "World"];
stringArray.push(1);
// 這會出現錯誤,字串陣列不允許加入數字。

const numberArray: number[] = [1, 2, 3, 4, 5, 6];
const numberMatrix: number[][] = [
  [1, 2, 3],
  [4, 5, 6],
  [7, 8, 9],
];
const stringOrNumberArray: (string | number)[] = [
  "Hello",
  " TypeScript",
  1,
  2,
  3,
];

type State = "idle" | "loading" | "loaded" | "error";
const stateArray: State[] = ["idle", "idle", "loading", "loaded"];