TypeScript 官方手冊翻譯計劃【二】:普通型別

Chor發表於2021-11-27
  • 說明:目前網上沒有 TypeScript 最新官方文件的中文翻譯,所以有了這麼一個翻譯計劃。因為我也是 TypeScript 的初學者,所以無法保證翻譯百分之百準確,若有錯誤,歡迎評論區指出;
  • 翻譯內容:暫定翻譯內容為 TypeScript Handbook,後續有空會補充翻譯文件的其它部分;
  • 專案地址TypeScript-Doc-Zh,如果對你有幫助,可以點一個 star ~

本章節官方文件地址:Everyday Types

普通型別

在這一章中,我們的內容會涉及到 JavaScript 程式碼中最常見的一些資料型別,同時也會解釋這些型別在 TypeScript 中的對應描述方式。本章節並不會詳盡介紹所有型別,在後續章節中我們還會介紹更多命名和使用其它型別的方法。

型別不僅可以出現在型別註解中,還可以出現在許多其它地方。在學習型別本身的同時,我們也會學習如何在某些地方使用這些型別去組成新的結構。

首先,我們先來回顧一下編寫 JavaScript 或者 TypeScript 程式碼時最基礎和最常用的型別。它們稍後將成為更復雜型別的核心組成部分。

原始型別:stringnumberboolean

JavaScript 有三種很常用的原始型別stringnumberboolean。每一種型別在 TypeScript 中都有相對應的型別。正如你所料,它們的名字就和使用 JavaScript 的 typeof 運算子得到的字串一樣:

  • string 表示類似 "Hello, world!" 這樣的字串值
  • number 表示類似 42 這樣的數值。對於整數,JavaScript 沒有特殊的執行時值,所以也就沒有 int 或者 float 型別 —— 所有的數字都是 number 型別
  • boolean 表示布林值 truefalse
型別名 StringNumberBoolean(大寫字母開頭)也是合法的,但它們指的是在程式碼中很少出現的內建型別。請始終使用 stringnumberboolean

陣列

為了表示類似 [1,2,3] 這樣的陣列型別,你可以使用語法 number[]。這種語法也可以用於任意型別(比如 string[] 表示陣列元素都是字串型別)。它還有另一種寫法是 Array<number>,兩者效果是一樣的。在後續講解泛型的時候,我們會再詳細介紹 T<U> 語法。

注意 [number]和普通陣列不同,它表示的是元組

any

TypeScript 還有一種特殊的 any 型別。當你不想要讓某個值引起型別檢查錯誤的時候,可以使用 any

當某個值是 any 型別的時候,你可以訪問它的任意屬性(這些屬性也會是 any 型別),可以將它作為函式呼叫,可以將它賦值給任意型別的值(或者把任意型別的值賦值給它),或者是任何語法上合規的操作:

let obj: any = { x: 0 };
// 下面所有程式碼都不會引起編譯錯誤。使用 any 將會忽略型別檢查,並且假定了
// 你比 TypeScript 更瞭解當前環境
obj.foo();
obj();
obj.bar = 100;
obj = "hello";
const n: number = obj;

當你不想要寫一長串型別讓 TypeScript 確信某行程式碼沒問題的時候,any 型別很管用。

noImplicitAny

當你沒有顯式指定一個型別,同時 TypeScript 也無法從上下文中進行型別推斷的時候,編譯器會預設將其作為 any 型別處理。

不過,通常你會避免這種情況的發生,因為 any 是會繞過型別檢查的。啟用 noImplicitAny 配置項可以將任意隱式推斷得到的 any 標記為一個錯誤。

變數的型別註解

當你使用 constvar 或者 let 宣告變數的時候,你可以選擇性地新增一個型別註解以顯式指定變數的型別:

let myName: string = 'Alice';
TypeScript 沒有采用類似 int x = 0 這樣“在表示式左邊宣告型別”的風格。型別註解總是跟在要宣告型別的東西后面。

不過,在大多數情況下,註解並不是必需的。TypeScript 會盡可能地在你的程式碼中自動進行型別推斷。舉個例子,變數的型別是基於它的初始值推斷出來的:

// 不需要新增型別註解 —— myName 會被自動推斷為 string 型別
let myName = 'Alice';

多數情況下,你不需要刻意去學習型別推斷的規則。如果你還是初學者,請嘗試儘可能少地使用型別註解 —— 你可能會驚訝地發現,TypeScript 完全理解所發生的事情所需要的註解是如此之少。

函式

函式是 JavaScript 中傳遞資料的主要方式。TypeScript 允許你指定函式的輸入和輸出的型別。

引數型別註解

當你宣告一個函式的時候,你可以在每個引數後面新增型別註解,從而宣告函式可以接受什麼型別的引數。引數的型別註解跟在每個引數名字的後面:

// 引數型別註解
function greet(name: string){
    console.log('Hello, ' + name.toUpperCase() + '!!');
}

當函式的某個引數有型別註解的時候,TypeScript 會對傳遞給函式的實參進行型別檢查:

// 如果執行,會有一個執行時錯誤!
greet(42);
// Argument of type 'number' is not assignable to parameter of type 'string'.
即使沒有給引數新增型別註解,TypeScript 也會檢查你傳遞的引數的個數是否正確

返回值型別註解

你也可以給返回值新增型別註解。返回值型別註解出現在引數列表後面:

function getFavourNumber(): number {
    return 26;
}

和變數的型別註解一樣,通常情況下我們不需要給返回值新增一個型別註解,因為 TypeScript 會基於 return 語句推斷出函式返回值的型別。上述例子中的型別註解不會改變任何事情。一些程式碼庫會顯式指定返回值的型別,這可能是出於文件編寫的需要,或者是為了防止意外的修改,或者只是個人喜好。

匿名函式

匿名函式和函式宣告有點不同。當一個函式出現在某個地方,且 TypeScript 可以推斷它是如何被呼叫的時候,該函式的引數會被自動分配型別。

比如:

// 這裡沒有型別註解,但 TypeScript 仍能在後續程式碼找出 bug
const names = ["Alice", "Bob", "Eve"];
 
// 基於上下文推斷匿名函式引數的型別
names.forEach(function (s) {
  console.log(s.toUppercase());
                 ^^^^^^^^^^^^  
// Property 'toUppercase' does not exist on type 'string'. Did you mean 'toUpperCase'?
});
 
// 對於箭頭函式,也可以正確推斷
names.forEach((s) => {
  console.log(s.toUppercase());
               ^^^^^^^^^^^^^
//Property 'toUppercase' does not exist on type 'string'. Did you mean 'toUpperCase'?
});

即使這裡沒有給引數 s 新增型別註解,TypeScript 也可以基於 forEach 函式的型別,以及對於 name 陣列型別的推斷,來決定 s 的型別。

這個過程叫做上下文型別推斷,因為函式呼叫時所處的上下文決定了它的引數的型別。

和推斷規則類似,你不需要刻意學習這個過程是怎麼發生的,但明確這個過程確實會發生之後,你自然就清楚什麼時候不需要新增型別註解了。稍後我們會看到更多的例子,瞭解到一個值所處的上下文是如何影響它的型別的。

物件型別

除了原始型別之外,最常見的型別就是物件型別了。它指的是任意包含屬性的 JavaScript 值。要定義一個物件型別,只需要簡單地列舉它的屬性和型別即可。

舉個例子,下面是一個接受物件型別作為引數的函式:

// 引數的型別註解是一個物件型別
function printCoord(pt: { x: number; y: number }) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}
printCoord({ x: 3, y: 7 });

這裡,我們為引數新增的型別註解是一個包含 xy 兩個屬性(型別都是 number)的物件。你可以使用 , 或者 ; 分隔每個屬性,最後一個屬性的分隔符可加可不加。

每個屬性的型別部分同樣也是可選的,如果你沒有指定型別,那麼它會採用 any 型別。

可選屬性

物件型別也可以指定某些或者全部屬性是可選的。你只需要在對應的屬性名後面新增一個 ? 即可:

function printName(obj: { first: string; last?: string }) {
  // ...
}
// 下面兩種寫法都行
printName({ first: "Bob" });
printName({ first: "Alice", last: "Alisson" });

在 JavaScript 中,如果你訪問了一個不存在的屬性,你將會得到 undefined 而不是一個執行時錯誤。因此,在你讀取一個可選屬性的時候,你需要在使用它之前檢查它是否為 undefined

function printName(obj: { first: string; last?: string }) {
  // 如果 obj.last 沒有對應的值,可能會報錯!
  console.log(obj.last.toUpperCase());
// Object is possibly 'undefined'.
  if (obj.last !== undefined) {
    // OK
    console.log(obj.last.toUpperCase());
  }
 
  // 下面是使用現代 JavaScript 語法的另一種安全寫法:
  console.log(obj.last?.toUpperCase());
}

聯合型別

TypeScript 的型別系統允許你基於既有的型別使用大量的運算子建立新的型別。既然我們已經知道了如何編寫基本的型別,是時候開始用一種有趣的方式將它們結合起來了。

定義一個聯合型別

第一種結合型別的方式就是使用聯合型別。聯合型別由兩個或者兩個以上的型別組成,它代表的是可以取這些型別中任意一種型別的值。每一種型別稱為聯合型別的成員。

我們來編寫一個可以處理字串或者數字的函式:

function printId(id: number | string) {
  console.log("Your ID is: " + id);
}
// OK
printId(101);
// OK
printId("202");
// 報錯
printId({ myID: 22342 });
// Argument of type '{ myID: number; }' is not assignable to parameter of type 'string | number'.
//  Type '{ myID: number; }' is not assignable to type 'number'.

使用聯合型別

提供一個匹配聯合型別的值非常簡單 —— 只需要提供一個與聯合型別某個成員相匹配的型別即可。如果有一個值是聯合型別,你要怎麼使用它呢?

TypeScript 會限制你對聯合型別可以採取的操作,僅當該操作對於聯合型別的每個成員都生效的時候,操作才會生效。舉個例子,如果你有聯合型別 string | number,那麼你將無法使用只能由 string 呼叫的方法:

function printId(id: number | string) {
  console.log(id.toUpperCase());
// Property 'toUpperCase' does not exist on type 'string | number'.
//  Property 'toUpperCase' does not exist on type 'number'.
}

解決方案就是在程式碼中去收窄聯合型別,這和沒有使用型別註解的 JavaScript 的做法一樣。當 TypeScript 能夠基於程式碼結構推斷出一個更具體的型別時,就會發生收窄。

舉個例子,TypeScript 知道只有 string 型別的值使用 typeof 之後會返回 "string"

function printId(id: number | string) {
  if (typeof id === "string") {
    // 在這個分支中,id 的型別是 string
    console.log(id.toUpperCase());
  } else {
    // 這裡,id 的型別是 number
    console.log(id);
  }
}

另一個例子是使用類似 Array.isArray 這樣的函式:

function welcomePeople(x: string[] | string) {
  if (Array.isArray(x)) {
    // 這裡,x 是 string[]
    console.log("Hello, " + x.join(" and "));
  } else {
    // 這裡,x 是 string
    console.log("Welcome lone traveler " + x);
  }
}

注意,在 else 分支中,我們不需要做額外的判斷 —— 如果 x 不是 string[],那它就一定是 string

有時候,聯合型別的所有成員可能存在共性。舉個例子,陣列和字串都有 slice 方法。如果一個聯合型別的每個成員都有一個公共的屬性,那麼你可以不需要進行收窄,直接使用該屬性:

// 返回值會被推斷為 number[] | string
function getFirstThree(x: number[] | string) {
  return x.slice(0, 3);
}
聯合型別的各個型別的屬性存在交集,你可能會覺得有點困惑。實際上這並不讓人意外,“聯合”這個名詞來自於型別理論。聯合型別 number | string 是由每個型別的值的聯合組成的。假設給定兩個集合以及各自對應的事實,那麼只有事實的交集可以應用於集合的交集本身。舉個例子,有一個屋子的人都很高,而且戴帽子,另一個屋子的人都是西班牙人,而且也戴帽子,那麼兩個屋子的人放到一起,我們可以得到的唯一事實就是:每個人肯定都戴著帽子。

型別別名

目前為止,我們都是在型別註解中直接使用物件型別或者聯合型別的。這很方便,但通常情況下,我們更希望通過一個單獨的名字多次引用某個型別。

型別別名就是用來做這個的 —— 它可以作為指代任意一種型別的名字。型別別名的語法如下:

type Point = {
  x: number;
  y: number;
};
 
// 效果和之前的例子完全一樣
function printCoord(pt: Point) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}
 
printCoord({ x: 100, y: 100 });

不止是物件型別,你可以給任意一種型別使用型別別名。舉個例子,你可以命名聯合型別:

type ID = number | string;

注意,別名就只是別名而已 —— 你不能使用型別別名去建立同一型別的不同“版本”。當你使用別名的時候,效果就和你直接編寫實際的型別一樣。換句話說,程式碼看起來是不合法的,但在 TypeScript 裡這是沒問題的,不管是別名還是實際型別,都指向同一個型別:

type UserInputSanitizedString = string;
 
function sanitizeInput(str: string): UserInputSanitizedString {
  return sanitize(str);
}
 
// 建立一個輸入
let userInput = sanitizeInput(getInput());
 
// 可以重新給它賦值一個字串
userInput = "new input";

介面

介面宣告是另一種命名物件型別的方式:

interface Point {
  x: number;
  y: number;
}
 
function printCoord(pt: Point) {
  console.log("The coordinate's x value is " + pt.x);
  console.log("The coordinate's y value is " + pt.y);
}
 
printCoord({ x: 100, y: 100 });

就和上面使用型別別名一樣,這個例子也可以正常執行,它的效果和直接使用一個匿名物件型別一樣。TypeScript 只關心我們傳遞給 printCoord 的值的結構 —— 它只關心這個值是否有期望的屬性。正是因為這種只關注型別的結構和能力的特點,所以我們說 TypeScript 是一個結構性的、型別性的型別系統。

型別別名和介面的區別

型別別名和介面很相似,多數情況下你可以任意選擇其中一個去使用。介面的所有特性幾乎都可以在型別別名中使用。兩者關鍵的區別在於型別別名無法再次“開啟”並新增新的屬性,而介面總是可以擴充的。

// 介面可以自由擴充
interface Animal {
  name: string
}

interface Bear extends Animal {
  honey: boolean
}

const bear = getBear() 
bear.name
bear.honey

// 型別別名需要通過交集進行擴充
type Animal = {
  name: string
}

type Bear = Animal & { 
  honey: boolean 
}

const bear = getBear();
bear.name;
bear.honey;

// 向既有的介面新增新的屬性
interface Window {
  title: string
}

interface Window {
  ts: TypeScriptAPI
}

const src = 'const a = "Hello World"';
window.ts.transpileModule(src, {});

// 型別別名一旦建立,就不能再修改了
type Window = {
  title: string
}

type Window = {
  ts: TypeScriptAPI
}

 // Error: Duplicate identifier 'Window'

在稍後的章節中,你會學到更多關於這方面的知識,所以現在還不太理解也沒關係。

  • 在 TypeScript 4.2 版本之前,型別別名的名字可能會出現在報錯資訊中,有時會代替等效的匿名型別(可能需要,也可能不需要)。而介面的名字則始終出現在報錯資訊中
  • 型別別名無法進行宣告合併,但介面可以
  • 介面只能用於宣告物件的形狀,無法為原始型別命名
  • 在報錯資訊中,介面的名字將始終以原始形式出現,但只限於它們作為名字被使用的時候

大多數情況下,你可以根據個人喜好選擇其中一種使用,TypeScript 也會告訴你它是否需要使用另一種宣告方式。如果你喜歡啟發式,那你可以使用介面,等到需要使用其他特性的時候,再使用型別別名。

型別斷言

有時候,你會比 TypeScript 更瞭解某個值的型別。

舉個例子,如果你使用 document.getElementById,那麼 TypeScript 只知道這個呼叫會返回某個 HTMLElement,但你卻知道你的頁面始終存在一個給定 ID 的 HTMLCanvasElement

在這種情況下,你可以使用型別斷言去指定一個更具體的型別:

const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;

就像型別註解一樣,編譯器最終會移除型別斷言,保證它不會影響到程式碼的執行時行為。

你也可以使用等效的尖括號語法(前提是程式碼不是在一個 .tsx 檔案中):

const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");
記住:因為編譯期間會移除型別斷言,所以不存在和型別斷言相關的執行時檢查。即使型別斷言是錯誤的,也不會丟擲異常或者產生 null

TypeScript 只允許斷言之後的型別比之前的型別更具體或者更不具體。這個規則可以防止出現下面這樣“不可能存在的”強制型別轉換:

const x = "hello" as number;
// 型別 "string" 到型別 "number" 的轉換可能是錯誤的,因為兩種型別不能充分重疊。如果這是有意的,請先將表示式轉換為 "unknown"

有時候,這個規則可能過於保守了,會阻礙我們進行更復雜的有效轉換操作。如果是這樣,那麼可以使用兩步斷言,先斷言為 any(或者 unknown,稍後再介紹),再斷言為期望的型別:

const a = (expr as any) as T;

字面量型別

除了通用的 stringnumber 型別之外,我們也可以將具體的字串或者數字看作一種型別。

怎麼理解呢?其實我們只需要考慮 JavaScript 宣告變數的不同方式即可。varlet 宣告的變數都可以修改,但 const 不行。這種特點反映在 TypeScript 是如何為字面量建立型別的。

let changingString = "Hello World";
changingString = "Olá Mundo";
// 因為 changingString 可以表示任意可能的字串,這是 TypeScript 
// 在型別系統中描述它的方式
changingString;
^^^^^^^^^^^^^^
    // let changingString: string
      
let changingString: string
 
const constantString = "Hello World";
// 因為 constantString 只能表示一種可能的字串,所以它有一個
// 字面量型別的表示形式
constantString;
^^^^^^^^^^^^^^^
    // const constantString: "Hello World"
      

只是單獨使用的話,字面量型別的用處並不大:

let x: "hello" = "hello";
// OK
x = "hello";
// ...
x = "howdy";
// Type '"howdy"' is not assignable to type '"hello"'.

上面的例子中,變數只有一個可能的值,這是沒有意義的!

但是通過將字面量型別結合為聯合型別,你可以表示一個更有實用價值的概念 —— 舉個例子,宣告一個只接受某些固定值的函式:

function printText(s: string, alignment: "left" | "right" | "center") {
  // ...
}
printText("Hello, world", "left");
printText("G'day, mate", "centre");
                        ^^^^^^        
// Argument of type '"centre"' is not assignable to parameter of type '"left" | "right" | "center"'.

數值型字面量型別也同理:

function compare(a: string, b: string): -1 | 0 | 1 {
  return a === b ? 0 : a > b ? 1 : -1;
}

當然,聯合型別中也可以包含非字面量型別:

interface Options {
  width: number;
}
function configure(x: Options | "auto") {
  // ...
}
configure({ width: 100 });
configure("auto");
configure("automatic");
          ^^^^^^^^^^    
// Argument of type '"automatic"' is not assignable to parameter of type 'Options | "auto"'.

還有一種字面量型別:布林值字面量。只有兩種布林值字面量型別,也就是 truefalseboolean 型別本身其實就是聯合型別 true | false 的一個別名。

字面量推斷

當你初始化一個變數為某個物件的時候,TypeScript 會假定該物件的屬性稍後可能會發生變化。比如下面的程式碼:

const obj = { counter: 0 };
if (someCondition) {
  obj.counter = 1;
}

TypeScript 不覺得將之前值為 0 的屬性賦值為 1 是一個錯誤。另一種理解角度是,obj.counter 必須是 number 型別,而不是 0,因為型別可以用來決定讀寫行為。

對於字串也同理:

const req = { url: "https://example.com", method: "GET" };
handleRequest(req.url, req.method);
// Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'.

(譯者注:這裡的 handleRequest 簽名為 (url: string, method: "GET" | "POST") => void)

在上面的例子中,req.method 被推斷為 string,而不是 "GET"。因為在建立 req 和呼叫 handleRequest 之間可能會執行其它程式碼,req.method 也許會被賦值為類似 "GUESS" 這樣的字串,因此 TypeScript 會認為這樣的程式碼是存在錯誤的。

有兩種方式可以解決這個問題:

  1. 通過新增型別斷言改變型別的推斷結果:

    // 方法一:
    const req = { url: "https://example.com", method: "GET" as "GET" };
    // 方法二:
    handleRequest(req.url, req.method as "GET");

​ 方法一表示“我有意讓 req.method 一直採用字面量型別 "GET"”,從而阻止後續將其賦值為其它字串;方法二表示“出於某種理由,我確信 req.method 的值一定是“GET””。

  1. 你還可以使用 as const 將整個物件轉化為字面量型別:

    const req = { url: "https://example.com", method: "GET" } as const;
    handleRequest(req.url, req.method);

    as const 字尾和 const 的效果很像,但用於型別系統中。它可以確保物件的所有屬性都被賦予了一個字面量型別,而不是採用類似 string 或者 number 這樣較為通用的型別。

nullundefined

JavaScript 中有兩個原始值用於表示缺少的或者沒有初始化的值:nullundefined

TypeScript 對應地也有兩個名字和它們一樣的型別。它們的行為取決於你是否啟用了 strictNullChecks 選項。

禁用 strictNullChecks

禁用 strictNullChecks 選項之後,你仍然可以正常訪問可能為 nullundefined 的值,這兩個值也可以被賦值給任何一種型別。這種行為表現和缺少空值檢查的語言(比如 C#、Java)很像。缺少對這些值的檢查可能是大量 bug 的來源,在可行的前提下,我們推薦開發者始終啟用 strictNullChecks 選項。

啟用 strictNullChecks

啟用 strictNullChecks 選項之後,當一個值是 null 或者 undefined 的時候,你需要在使用該值的方法或者屬性之前首先對其進行檢查。就和使用可選屬性之前先檢查它是否為 undefined 一樣,我們可以使用型別收窄去檢查某個值是否可能為 null

function doSomething(x: string | null) {
  if (x === null) {
    // do nothing
  } else {
    console.log("Hello, " + x.toUpperCase());
  }
}

非空值斷言操作符(! 字尾)

TypeScript 也提供了一種特殊的語法,可以在不顯式進行檢查的情況下,將 nullundefined 從型別中排除。在任意表示式後面新增字尾 !,可以有效地斷言某個值不可能為 null 或者 undefined

function liveDangerously(x?: number | null) {
  // 不會報錯
  console.log(x!.toFixed());
}

和其它的型別斷言一樣,非空值斷言也不會改變程式碼的執行時行為,所以切記:僅在你確定某個值不可能為 null 或者 undefined 的時候,才去使用 !

列舉

列舉是 TypeScript 新增到 JavaScript 中的一項特性。它允許描述一個值,該值可以是一組可能的命名常量中的一個。與大多數的 TypeScript 特性不同,列舉不是在型別層面新增到 JavaScript 中的,而是新增到語言本身和它的執行時中。正因如此,你應該瞭解這個特性的存在,但除非你確定,否則你可能需要推遲使用它。你可以在列舉引用頁面中瞭解到有關列舉的更多資訊。

其它不常見的原始型別

值得一提的是,JavaScript 的其它原始型別在型別系統中也有對應的表示形式。不過在這裡我們不會深入進行探討。

BigInt

ES2020 引入了 BigInt,用於表示 JavaScript 中非常大的整數:

// 通過 BigInt 函式建立大整數
const oneHundred: bigint = BigInt(100);
 
// 通過字面量語法建立大整數
const anotherHundred: bigint = 100n;

你可以在 TypeScript 3.2 釋出日誌 中瞭解到關於 BigInt 的更多資訊。

symbol

在 JavaScript 中,我們可以通過函式 Symbol() 建立一個全域性唯一的引用:

const firstName = Symbol("name");
const secondName = Symbol("name");
 
if (firstName === secondName) {
// 此條件將始終返回 "false",因為型別 "typeof firstName" 和 "typeof secondName" 沒有重疊。    
}

你可以在 Symbol 引用頁面 瞭解到更多相關資訊。

相關文章