Desmond

Desmond

An introvert who loves web programming, graphic design and guitar
github
bilibili
twitter

TypeScript 類型編程

類型別名#

抽離一組聯合類型:

type StatusCode = 200 | 301 | 400 | 500 | 502;
type PossibleDataTypes = string | number | (() => unknown);

const status: StatusCode = 502;

抽離一個函數類型:

type Handler = (e: Event) => void;

const clickHandler: Handler = (e) => { };
const moveHandler: Handler = (e) => { };
const dragHandler: Handler = (e) => { };

在類型別名中,類型別名可以這麼聲明自己能夠接受泛型(我稱之為泛型坑位)。一旦接受了泛型,我們就叫它工具類型:

type MaybeNull<T> = T | null;
function process(input: MaybeNull<{ handler: () => {} }>) {
  input?.handler();
}
type MaybeArray<T> = T | T[];

// 函數泛型我們會在後面了解~
function ensureArray<T>(input: MaybeArray<T>): T[] {
  return Array.isArray(input) ? input : [input];
}

聯合類型與交叉類型#

實際上,正如聯合類型的符號是|,它代表了按位或,即只需要符合聯合類型中的一個類型,既可以認為實現了這個聯合類型,如A | B,只需要實現 A 或 B 即可。

而代表著按位與的 & 則不同,你需要符合這裡的所有類型,才可以說實現了這個交叉類型,即 A & B需要同時滿足 A 與 B 兩個類型才行。

interface NameStruct {
  name: string;
}

interface AgeStruct {
  age: number;
}

type ProfileStruct = NameStruct & AgeStruct;

const profile: ProfileStruct = {
  name: "linbudu",
  age: 18
}
type StrAndNum = string & number; // never
type UnionIntersection1 = (1 | 2 | 3) & (1 | 2); // 1 | 2
type UnionIntersection2 = (string | number | symbol) & string; // string

索引類型#

索引簽名類型#

索引簽名類型主要指的是在介面或類型別名中,通過以下語法來快速聲明一個鍵值類型一致的類型結構

interface AllStringTypes {
  [key: string]: string;
}

type PropType1 = AllStringTypes['linbudu']; // string
type PropType2 = AllStringTypes['599']; // string

但由於 JavaScript 中,對於 obj[prop] 形式的訪問會將數字索引訪問轉換為字符串索引訪問,也就是說, obj[599] obj['599'] 的效果是一致的。因此,在字符串索引簽名類型中我們仍然可以聲明數字類型的鍵。類似的,symbol 類型也是如此:

const foo: AllStringTypes = {
  "linbudu": "599",
  599: "linbudu",
  [Symbol("ddd")]: 'symbol',
}

索引簽名類型也可以和具體的鍵值對類型聲明並存,但這時這些具體的鍵值類型也需要符合索引簽名類型的聲明:

interface AllStringTypes {
  // 類型“number”的屬性“propA”不能賦給“string”索引類型“boolean”。
  propA: number;
  [key: string]: boolean;
}

interface StringOrBooleanTypes {
  propA: number;
  propB: boolean;
  [key: string]: number | boolean;
}

索引簽名類型的一個常見場景是在重構 JavaScript 代碼時,為內部屬性較多的對象聲明一個 any 的索引簽名類型,以此來暫時支持對類型未明確屬性的訪問,並在後續一點點補全類型:

interface AnyTypeHere {
  [key: string]: any;
}

const foo: AnyTypeHere['linbudu'] = 'any value';

索引類型查詢#

keyof 操作符可以將對象中的所有鍵轉換為對應字面量類型,然後再組合成聯合類型。注意,這裡並不會將數字類型的鍵名轉換為字符串類型字面量,而是仍然保持為數字類型字面量

interface Foo {
  linbudu: 1,
  599: 2
}

type FooKeys = keyof Foo; // "linbudu" | 599
// 在 VS Code 中懸浮鼠標只能看到 'keyof Foo'
// 看不到其中的實際值,你可以這麼做:
type FooKeys = keyof Foo & {}; // "linbudu" | 599

索引類型訪問#

索引類型查詢的本質其實就是,通過鍵的字面量類型訪問這個鍵對應的鍵值類型

interface NumberRecord {
  [key: string]: number;
}

type PropType = NumberRecord[string]; // number
interface Foo {
  propA: number;
  propB: boolean;
}

type PropAType = Foo['propA']; // number
type PropBType = Foo['propB']; // boolean
interface Foo {
  propA: number;
  propB: boolean;
  propC: string;
}

type PropTypeUnion = Foo[keyof Foo]; // string | number | boolean

映射類型#

映射類型的主要作用即是基於鍵名映射到鍵值類型

type Stringify<T> = {
  [K in keyof T]: string;
};

interface Foo {
  prop1: string;
  prop2: number;
  prop3: boolean;
  prop4: () => void;
}

type StringifiedFoo = Stringify<Foo>;

// 等價於
interface StringifiedFoo {
  prop1: string;
  prop2: string;
  prop3: string;
  prop4: string;
}

這個工具類型會接受一個對象類型(假設我們只會這麼用),使用 keyof 獲得這個對象類型的鍵名組成字面量聯合類型,然後通過映射類型(即這裡的 in 關鍵字)將這個聯合類型的每一個成員映射出來,並將其鍵值類型設置為 string。

type Clone<T> = {
  [K in keyof T]: T[K];
};

這裡的T[K]其實就是上面說到的索引類型訪問,我們使用鍵的字面量類型訪問到了鍵值的類型,這裡就相當於克隆了一個介面。需要注意的是,這裡其實只有K in 屬於映射類型的語法,keyof T 屬於 keyof 操作符,[K in keyof T][]屬於索引簽名類型,T[K]屬於索引類型訪問。

image

類型查詢操作符 Type Query Operator#

const str = "linbudu";

const obj = { name: "linbudu" };

const nullVar = null;
const undefinedVar = undefined;

const func = (input: string) => {
  return input.length > 10;
}

type Str = typeof str; // "linbudu"
type Obj = typeof obj; // { name: string; }
type Null = typeof nullVar; // null
type Undefined = typeof undefined; // undefined
type Func = typeof func; // (input: string) => boolean
const func = (input: string) => {
  return input.length > 10;
}

const func2: typeof func = (name: string) => {
  return name === 'linbudu'
}
const func = (input: string) => {
  return input.length > 10;
}

// boolean
type FuncReturnType = ReturnType<typeof func>;

絕大部分情況下,typeof 返回的類型就是當你把鼠標懸浮在變量名上時出現的推導後的類型,並且是最窄的推導程度(即到字面量類型的級別)。你也不必擔心混用了這兩種 typeof,在邏輯代碼中使用的 typeof 一定會是 JavaScript 中的 typeof,而類型代碼(如類型標註、類型別名中等)中的一定是類型查詢的 typeof 。同時,為了更好地避免這種情況,也就是隔離類型層和邏輯層,類型查詢操作符後是不允許使用表達式的:

const isInputValid = (input: string) => {
  return input.length > 10;
}

// 不允許表達式
let isValid: typeof isInputValid("linbudu");

類型守衛#

TypeScript 中提供了非常強大的類型推導能力,它會隨著你的代碼邏輯不斷嘗試收窄類型,這一能力稱之為類型的控制流分析(也可以簡單理解為類型推導)。這即是編程語言的類型能力中最重要的一部分:與實際邏輯緊密關聯的類型。我們從邏輯中進行類型地推導,再反過來讓類型為邏輯保駕護航。

function isString(input: unknown): boolean {
  return typeof input === "string";
}

function foo(input: string | number) {
  if (isString(input)) {
    // 類型“string | number”上不存在屬性“replace”。
    (input).replace("linbudu", "linbudu599")
  }
  if (typeof input === 'number') { }
  // ...
}

因為 isString 這個函數在另外一個地方,內部的判斷邏輯並不在函數 foo 中。這裡的類型控制流分析做不到跨函數上下文來進行類型的信息收集(但別的類型語言中可能是支持的)。

實際上,將判斷邏輯封裝起來提取到函數外部進行復用非常常見。為了解決這一類型控制流分析的能力不足, TypeScript 引入了 is 關鍵字來顯式地提供類型信息:

function isString(input: unknown): input is string {
  return typeof input === "string";
}

function foo(input: string | number) {
  if (isString(input)) {
    // 正確了
    (input).replace("linbudu", "linbudu599")
  }
  if (typeof input === 'number') { }
  // ...
}

is string,即 is 關鍵字 + 預期類型,即如果這個函數成功返回為 true,那麼 is 關鍵字前這個入參的類型,就會被這個類型守衛調用方後續的類型控制流分析收集到

需要注意的是,類型守衛函數中並不會對判斷邏輯和實際類型的關聯進行檢查:

function isString(input: unknown): input is number {
  return typeof input === "string";
}

function foo(input: string | number) {
  if (isString(input)) {
    // 報錯,在這裡變成了 number 類型
    (input).replace("linbudu", "linbudu599")
  }
  if (typeof input === 'number') { }
  // ...
}

從這個角度來看,其實類型守衛有些類似於類型斷言,但類型守衛更寬容,也更信任你一些。你指定什麼類型,它就是什么類型。

export type Falsy = false | "" | 0 | null | undefined;

export const isFalsy = (val: unknown): val is Falsy => !val;

// 不包括不常用的 symbol 和 bigint
export type Primitive = string | number | boolean | undefined;

export const isPrimitive = (val: unknown): val is Primitive => ['string', 'number', 'boolean' , 'undefined'].includes(typeof val);

基於 in 與 instanceof 的類型保護#

interface Foo {
  foo: string;
  fooOnly: boolean;
  shared: number;
}

interface Bar {
  bar: string;
  barOnly: boolean;
  shared: number;
}

function handle(input: Foo | Bar) {
  if ('foo' in input) {
    input.fooOnly;
  } else {
    input.barOnly;
  }
}

function handle(input: Foo | Bar) {
  if ('shared' in input) {
    // 類型“Foo | Bar”上不存在屬性“fooOnly”。類型“Bar”上不存在屬性“fooOnly”。
    input.fooOnly;
  } else {
    // 類型“never”上不存在屬性“barOnly”。
    input.barOnly;
  }
}

這個時候肯定有人想問,為什麼 shared 不能用來區分?答案很明顯,因為它同時存在兩個類型中不具有辨識度。而 foo /bar 和 fooOnly /barOnly 是各個類型獨有的屬性,因此可以作為可辨識屬性(Discriminant Property 或 Tagged Property)。Foo 與 Bar 又因為存在這樣具有區分能力的辨識屬性,可以稱為可辨識聯合類型(Discriminated Unions 或 Tagged Union)。雖然它們是一堆類型的聯合體,但其中每一個類型都具有一個獨一無二的,能讓它鶴立雞群的屬性。

這個可辨識屬性可以是結構層面的,比如結構 A 的屬性 prop 是數組,而結構 B 的屬性 prop 是對象,或者結構 A 中存在屬性 prop 而結構 B 中不存在。

它甚至可以是共同屬性的字面量類型差異:

function ensureArray(input: number | number[]): number[] {
  if (Array.isArray(input)) {
    return input;
  } else {
    return [input];
  }
}

interface Foo {
  kind: 'foo';
  diffType: string;
  fooOnly: boolean;
  shared: number;
}

interface Bar {
  kind: 'bar';
  diffType: number;
  barOnly: boolean;
  shared: number;
}

function handle1(input: Foo | Bar) {
  if (input.kind === 'foo') {
    input.fooOnly;
  } else {
    input.barOnly;
  }
}

function handle2(input: Foo | Bar) {
  // 報錯,並沒有起到區分的作用,在兩個代碼塊中都是 Foo | Bar
  if (typeof input.diffType === 'string') {
    input.fooOnly;
  } else {
    input.barOnly;
  }
}
class FooBase {}

class BarBase {}

class Foo extends FooBase {
  fooOnly() {}
}
class Bar extends BarBase {
  barOnly() {}
}

function handle(input: Foo | Bar) {
  if (input instanceof FooBase) {
    input.fooOnly();
  } else {
    input.barOnly();
  }
}

類型斷言守衛#

斷言守衛和類型守衛最大的不同點在於,在判斷條件不通過時,斷言守衛需要拋出一個錯誤,類型守衛只需要剔除掉預期的類型。

function assert(condition: any, msg?: string): asserts condition {
  if (!condition) {
    throw new Error(msg);
  }
}

這裡使用的是 asserts condition ,而 condition 來自於實際邏輯!這也意味著,我們將 condition 這一邏輯層面的代碼,作為了類型層面的判斷依據,相當於在返回值類型中使用一個邏輯表達式進行了類型標註。

let name: any = 'linbudu';

function assertIsNumber(val: any): asserts val is number {
  if (typeof val !== 'number') {
    throw new Error('Not a number!');
  }
}

assertIsNumber(name);

// number 類型!
name.toFixed();

在這種情況下,你無需再為斷言守衛傳入一個表達式,而是可以將這個判斷用的表達式放進斷言守衛的內部,來獲得更獨立地代碼邏輯。


References:
https://juejin.cn/book/7086408430491172901

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。