讓你更好使用 Typescript 的11個技巧

前端小智發表於2023-01-13
微信搜尋 【大遷世界】, 我會第一時間和你分享前端行業趨勢,學習途徑等等。
本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

學習Typescript通常是一個重新發現的過程。最初印象可能很有欺騙性:這不就是一種註釋Javascript 的方式嗎,這樣編譯器就能幫助我找到潛在的bug?

雖然這種說法總體上是正確的,但隨著你的前進,會發現語言最不可思議的力量在於組成、推斷和操縱型別。

本文將總結幾個技巧,幫助你充分發揮語言的潛力。

將型別想象成集合

型別是程式設計師日常概念,但很難簡明地定義它。我發現用集合作為概念模型很有幫助。

例如,新的學習者發現Typescript組成型別的方式是反直覺的。舉一個非常簡單的例子:

type Measure = { radius: number };
type Style = { color: string };

// typed { radius: number; color: string }
type Circle = Measure & Style;

如果你將 & 運算子解釋為邏輯,你的可能會認為 Circle 是一個啞巴型別,因為它是兩個沒有任何重疊欄位的型別的結合。這不是 TypeScript 的工作方式。相反,將其想象成集合會更容易推匯出正確的行為:

  • 每種型別都是值的集合
  • 有些集合是無限的,如 string、object;有些是有限的,如 boolean、undefined,...
  • unknown 是通用集合(包括所有值),而 never 是空集合(不包括任何值)
  • Type Measure 是一個集合,包含所有包含名為 radius 的 number 欄位的物件。Style 也是如此。
  • &運算子建立了交集:Measure & Style 表示包含 radiuscolor 欄位的物件的集合,這實際上是一個較小的集合,但具有更多常用欄位。
  • 同樣,|運算子建立了並集:一個較大的集合,但可能具有較少的常用欄位(如果兩個物件型別組合在一起)

集合也有助於理解可分配性:只有當值的型別是目標型別的子集時才允許賦值:

type ShapeKind = 'rect' | 'circle';
let foo: string = getSomeString();
let shape: ShapeKind = 'rect';

// 不允許,因為字串不是 ShapeKind 的子集。
shape = foo;

// 允許,因為 ShapeKind 是字串的子集。
foo = shape;

理解型別宣告和型別收窄

TypeScript 有一項非常強大的功能是基於控制流的自動型別收窄。這意味著在程式碼位置的任何特定點,變數都具有兩種型別:宣告型別和型別收窄。

function foo(x: string | number) {
  if (typeof x === 'string') {
    // x 的型別被縮小為字串,所以.length是有效的
    console.log(x.length);

    // assignment respects declaration type, not narrowed type
    x = 1;
    console.log(x.length); // disallowed because x is now number
    } else {
        ...
    }
}

使用帶有區分的聯合型別而不是可選欄位

在定義一組多型型別(如 Shape)時,可以很容易地從以下開始:

type Shape = {
  kind: 'circle' | 'rect';
  radius?: number;
  width?: number;
  height?: number;
}

function getArea(shape: Shape) {
  return shape.kind === 'circle' ? 
    Math.PI * shape.radius! ** 2
    : shape.width! * shape.height!;
}

需要使用非空斷言(在訪問 radiuswidthheight 欄位時),因為 kind 與其他欄位之間沒有建立關係。相反,區分聯合是一個更好的解決方案:

type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;

function getArea(shape: Shape) {
    return shape.kind === 'circle' ? 
        Math.PI * shape.radius ** 2
        : shape.width * shape.height;
}

型別收窄已經消除了強制轉換的需要。

使用型別謂詞來避免型別斷言

如果你正確使用 TypeScript,你應該很少會發現自己使用顯式型別斷言(例如 value as SomeType);但是,有時你仍然會有一種衝動,例如:

type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;

function isCircle(shape: Shape) {
  return shape.kind === 'circle';
}

function isRect(shape: Shape) {
  return shape.kind === 'rect';
}

const myShapes: Shape[] = getShapes();
// 錯誤,因為typescript不知道過濾的方式
const circles: Circle[] = myShapes.filter(isCircle);

// 你可能傾向於新增一個斷言
// const circles = myShapes.filter(isCircle) as Circle[];

一個更優雅的解決方案是將isCircleisRect改為返回型別謂詞,這樣它們可以幫助Typescript在呼叫 filter 後進一步縮小型別。

function isCircle(shape: Shape): shape is Circle {
    return shape.kind === 'circle';
}

function isRect(shape: Shape): shape is Rect {
    return shape.kind === 'rect';
}

...
// now you get Circle[] type inferred correctly
const circles = myShapes.filter(isCircle);

控制聯合型別的分佈方式

型別推斷是Typescript的本能;大多數時候,它公默默地工作。但是,在模糊不清的情況下,我們可能需要干預。分配條件型別就是其中之一。

假設我們有一個ToArray輔助型別,如果輸入的型別不是陣列,則返回一個陣列型別。

type ToArray<T> = T extends Array<unknown> ? T: T[];

你認為對於以下型別,應該如何推斷?

type Foo = ToArray<string|number>;

答案是string[] | number[]。但這是有歧義的。為什麼不是(string | number)[] 呢?

預設情況下,當typescript遇到一個聯合型別(這裡是string | number)的通用引數(這裡是T)時,它會分配到每個組成元素,這就是為什麼這裡會得到string[] | number[]。這種行為可以透過使用特殊的語法和用一對[]來包裝T來改變,比如。

type ToArray<T> = [T] extends [Array<unknown>] ? T : T[];
type Foo = ToArray<string | number>;

現在,Foo 被推斷為型別(string | number)[]

使用窮舉式檢查,在編譯時捕捉未處理的情況

在對列舉進行 switch-case 操作時,最好是積極地對不期望的情況進行錯誤處理,而不是像在其他程式語言中那樣默默地忽略它們:

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rect':
      return shape.width * shape.height;
    default:
      throw new Error('Unknown shape kind');
  }
}

使用Typescript,你可以透過利用never型別,讓靜態型別檢查提前為你找到錯誤:

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rect':
      return shape.width * shape.height;
    default:
      // 如果任何shape.kind沒有在上面處理
      // 你會得到一個型別檢查錯誤。
      const _exhaustiveCheck: never = shape;
      throw new Error('Unknown shape kind');
  }
}

有了這個,在新增一個新的shape kind時,就不可能忘記更新getArea函式。

這種技術背後的理由是,never 型別除了 never 之外不能賦值給任何東西。如果所有的 shape.kind 候選者都被 case 語句消耗完,到達 default 的唯一可能的型別就是 never;但是,如果有任何候選者沒有被覆蓋,它就會洩漏到 default 分支,導致無效賦值。

優先選擇 type 而不是 interface

在 TypeScript 中,當用於對物件進行型別定義時,typeinterface 構造很相似。儘管可能有爭議,但我的建議是在大多數情況下一貫使用 type,並且僅在下列情況之一為真時使用 interface

  • 你想利用interface的 "合併"功能。
  • 你有遵循物件導向風格的程式碼,其中包含類/介面層次結構

否則,總是使用更通用的型別結構會使程式碼更加一致。

在適當的時候優先選擇元組而不是陣列

物件型別是輸入結構化資料的常見方式,但有時你可能希望有更多的表示方法,並使用簡單的陣列來代替。例如,我們的Circle可以這樣定義:

type Circle = (string | number)[];
const circle: Circle = ['circle', 1.0];  // [kind, radius]

但是這種型別檢查太寬鬆了,我們很容易透過建立類似 ['circle', '1.0'] 的東西而犯錯。我們可以透過使用 Tuple 來使它更嚴格:

type Circle = [string, number];

// 這裡會得到一個錯誤
const circle: Circle = ['circle', '1.0'];

Tuple使用的一個好例子是React的useState

const [name, setName] = useState('');

它既緊湊又有型別安全。

控制推斷的型別的通用性或特殊性

在進行型別推理時,Typescript使用了合理的預設行為,其目的是使普通情況下的程式碼編寫變得簡單(所以型別不需要明確註釋)。有幾種方法可以調整它的行為。

使用const來縮小到最具體的型別

let foo = { name: 'foo' }; // typed: { name: string }
let Bar = { name: 'bar' } as const; // typed: { name: 'bar' }

let a = [1, 2]; // typed: number[]
let b = [1, 2] as const; // typed: [1, 2]

// typed { kind: 'circle; radius: number }
let circle = { kind: 'circle' as const, radius: 1.0 };

// 如果circle沒有使用const關鍵字進行初始化,則以下內容將無法正常工作
let shape: { kind: 'circle' | 'rect' } = circle;

使用satisfies來檢查型別,而不影響推斷的型別

考慮以下例子:

type NamedCircle = {
    radius: number;
    name?: string;
};

const circle: NamedCircle = { radius: 1.0, name: 'yeah' };

// error because circle.name can be undefined
console.log(circle.name.length);

我們遇到了錯誤,因為根據circle的宣告型別NamedCirclename欄位確實可能是undefined,即使變數初始值提供了字串值。當然,我們可以刪除:NamedCircle型別註釋,但我們將為circle物件的有效性丟失型別檢查。相當的困境。

幸運的是,Typescript 4.9 引入了一個新的satisfies關鍵字,允許你在不改變推斷型別的情況下檢查型別。

type NamedCircle = {
    radius: number;
    name?: string;
};

// error because radius violates NamedCircle
const wrongCircle = { radius: '1.0', name: 'ha' }
    satisfies NamedCircle;

const circle = { radius: 1.0, name: 'yeah' }
    satisfies NamedCircle;

// circle.name can't be undefined now
console.log(circle.name.length);

修改後的版本享有這兩個好處:保證物件字面意義符合NamedCircle型別,並且推斷出的型別有一個不可為空的名字欄位。

使用infer建立額外的泛型型別引數

在設計實用功能和型別時,我們經常會感到需要使用從給定型別引數中提取出的型別。在這種情況下,infer關鍵字非常方便。它可以幫助我們實時推斷新的型別引數。這裡有兩個簡單的示例:

//  從一個Promise中獲取未被包裹的型別
// idempotent if T is not Promise
type ResolvedPromise<T> = T extends Promise<infer U> ? U : T;
type t = ResolvedPromise<Promise<string>>; // t: string

// gets the flattened type of array T;
// idempotent if T is not array
type Flatten<T> = T extends Array<infer E> ? Flatten<E> : T;
type e = Flatten<number[][]>; // e: number

T extends Promise<infer U>中的infer關鍵字的工作方式可以理解為:假設T與某些例項化的通用Promise型別相容,即時建立型別引數U使其工作。因此,如果T被例項化為Promise<string>,則U的解決方案將是string

透過在型別操作方面保持創造力來保持DRY(不重複)

Typescript提供了強大的型別操作語法和一套非常有用的工具,幫助你把程式碼重複率降到最低。

不是重複宣告:

type User = {
    age: number;
    gender: string;
    country: string;
    city: string
};
type Demographic = { age: number: gender: string; };
type Geo = { country: string; city: string; };

而是使用Pick工具來提取新的型別:

type User = {
    age: number;
    gender: string;
    country: string;
    city: string
};
type Demographic = Pick<User, 'age'|'gender'>;
type Geo = Pick<User, 'country'|'city'>;

不是重複函式的返回型別

function createCircle() {
    return {
        kind: 'circle' as const,
        radius: 1.0
    }
}

function transformCircle(circle: { kind: 'circle'; radius: number }) {
    ...
}

transformCircle(createCircle());

而是使用ReturnType<T>來提取它:

function createCircle() {
    return {
        kind: 'circle' as const,
        radius: 1.0
    }
}

function transformCircle(circle: ReturnType<typeof createCircle>) {
    ...
}

transformCircle(createCircle());

不是並行地同步兩種型別的形狀(這裡是typeof configFactory)。

type ContentTypes = 'news' | 'blog' | 'video';

// config for indicating what content types are enabled
const config = { news: true, blog: true, video: false }
    satisfies Record<ContentTypes, boolean>;

// factory for creating contents
type Factory = {
    createNews: () => Content;
    createBlog: () => Content;
};

而是使用Mapped TypeTemplate Literal Type,根據配置的形狀自動推斷適當的工廠型別。

type ContentTypes = 'news' | 'blog' | 'video';

// generic factory type with a inferred list of methods
// based on the shape of the given Config
type ContentFactory<Config extends Record<ContentTypes, boolean>> = {
    [k in string & keyof Config as Config[k] extends true
        ? `create${Capitalize<k>}`
        : never]: () => Content;
};

// config for indicating what content types are enabled
const config = { news: true, blog: true, video: false }
    satisfies Record<ContentTypes, boolean>;

type Factory = ContentFactory<typeof config>;
// Factory: {
//     createNews: () => Content;
//     createBlog: () => Content; 
// }

總結

本文涵蓋了Typescript語言中的一組相對高階的主題。在實踐中,您可能會發現直接使用它們並不常見;然而,這些技術被專門為Typescript設計的庫大量使用:比如Prisma和tRPC。瞭解這些技巧可以幫助您更好地瞭解這些工具如何在引擎蓋下工作。

編輯中可能存在的bug沒法實時知道,事後為了解決這些bug,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug

原文:https://dev.to/zenstack/11-ti...

交流

有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

相關文章