【乾貨】TypeScript 實戰之 extends、infer 與 dva type

小賊先生發表於2022-03-02
作者:小賊先生_ronffy

前言

本文主要講解 typescript 的 extendsinfer 和 template literal types 等知識點,針對每個知識點,我將分別使用它們解決一些日常開發中的實際問題。
最後,活用這些知識點,漸進的解決使用 dva 時的型別問題。

說明:

  1. extendsinfer 是 TS 2.8 版本推出的特性。
  2. Template Literal Types 是 TS 4.1 版本推出的特性。
  3. 本文非 typescript 入門文件,需要有一定的 TS 基礎,如 TS 基礎型別、介面、泛型等。

在正式講知識點之前,先丟擲幾個問題,請大家認真思考每個問題,接下來的講解會圍繞這些問題慢慢鋪開。

拋幾個問題

1. 獲取函式的引數型別

function fn(a: number, b: string): string {
  return a + b;
}

// 期望值 [a: number, b: string]
type FnArgs = /* TODO */

2. 如何定義 get 方法

class MyC {
  data = {
    x: 1,
    o: {
      y: '2',
    },
  };

  get(key) {
    return this.data[key];
  }
}

const c = new MyC();

// 1. x 型別應被推導為 number
const x = c.get('x');

// 2. y 型別應被推導為 string;z 不在 o 物件上,此處應 TS 報錯
const { y, z } = c.get('o');

// 3. c.data 上不存在 z 屬性,此處應 TS 報錯
const z = c.get('z');

3. 獲取 dva 所有的 Actions 型別

dva 是一個基於 redux 和 redux-saga 的資料流方案,是一個不錯的資料流解決方案。此處借用 dva 中 model 來學習如何更好的將 TS 在實踐中應用,如果對 dva 不熟悉也不會影響繼續往下學習。

// foo
type FooModel = {
  state: {
    x: number;
  };
  reducers: {
    add(
      S: FooModel['state'],
      A: {
        payload: string;
      },
    ): FooModel['state'];
  };
};

// bar
type BarModel = {
  state: {
    y: string;
  };
  reducers: {
    reset(
      S: BarModel['state'],
      A: {
        payload: boolean;
      },
    ): BarModel['state'];
  };
};

// models
type AllModels = {
  foo: FooModel;
  bar: BarModel;
};

問題:根據 AllModels 推匯出 Actions 型別

// 期望
type Actions =
  | {
      type: 'foo/add';
      payload: string;
    }
  | {
      type: 'bar/reset';
      payload: boolean;
    };

知識點

extends

extends 有三種主要的功能:型別繼承、條件型別、泛型約束。

型別繼承

語法:

interface I {}
class C {}
interface T extends I, C {}

示例:

interface Action {
  type: any;
}

interface PayloadAction extends Action {
  payload: any;
  [extraProps: string]: any;
}

// type 和 payload 是必傳欄位,其他欄位都是可選欄位
const action: PayloadAction = {
  type: 'add',
  payload: 1
}

條件型別(conditional-types)

extends 用在條件表示式中是條件型別。

語法:

T extends U ? X : Y

如果 T 符合 U 的型別範圍,返回型別 X,否則返回型別 Y

示例:

type LimitType<T> = T extends number ? number : string

type T1 = LimitType<string>; // string
type T2 = LimitType<number>; // number

如果 T 符合 number 的型別範圍,返回型別 number,否則返回型別 string

泛型約束

可以使用 extends 來約束泛型的範圍和形狀。

示例:
目標:呼叫 dispatch 方法時對傳參進行 TS 驗證:typepayload 是必傳屬性,payload 型別是 number

// 期望:ts 報錯:缺少屬性 "payload"
dispatch({
  type: 'add',
})

// 期望:ts 報錯:缺少屬性 "type"
dispatch({
  payload: 1
})

// 期望:ts 報錯:不能將型別“string”分配給型別“number”。
dispatch({
  type: 'add',
  payload: '1'
})

// 期望:正確
dispatch({
  type: 'add',
  payload: 1
})

實現:

// 增加泛型 P,使用 PayloadAction 時有能力對 payload 進行型別定義
interface PayloadAction<P = any> extends Action {
  payload: P;
  [extraProps: string]: any;
}

// 新增:Dispatch 型別,泛型 A 應符合 Action
type Dispatch<A extends Action> = (action: A) => A;

// 備註:此處 dispatch 的 js 實現只為示例說明,非 redux 中的真實實現
const dispatch: Dispatch<PayloadAction<number>> = (action) => action;

infer

條件型別中的型別推導。

示例 1:

// 推導函式的返回型別
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

function fn(): number {
  return 0;
}

type R = ReturnType<typeof fn>; // number

如果 T 可以分配給型別 (...args: any[]) => any,返回 R,否則返回型別 anyR 是在使用 ReturnType 時,根據傳入或推導的 T 函式型別推匯出函式返回值的型別。

示例 2:取出陣列中的型別

type ArrayItemType<T> = T extends (infer U)[] ? U : T;

type T1 = ArrayItemType<string>; // string
type T2 = ArrayItemType<Date[]>; // Date
type T3 = ArrayItemType<number[]>; // number

模版字串型別(Template Literal Types)

模版字串用反引號(\`)標識,模版字串中的聯合型別會被展開後排列組合。

示例:

function request(api, options) {
  return fetch(api, options);
}

如何用 TS 約束 apihttps://abc.com 開頭的字串?

type Api = `${'http' | 'https'}://abc.com${string}`; // `http://abc.com${string}` | `https://abc.com${string}`
作者:小賊先生_ronffy

解決問題

現在,相信你已掌握了 extendsinfer 和 template literal types,接下來,讓我們逐一解決文章開頭丟擲的問題。

Fix: Q1 獲取函式的引數型別

上面已學習了 ReturnType,知道了如何通過 extendsinfer 獲取函式的返回值型別,下面看看如何獲取函式的引數型別。

type Args<T> = T extends (...args: infer A) => any ? A : never;

type FnArgs = Args<typeof fn>;

Fix: Q2 如何定義 get 方法

class MyC {
  get<T extends keyof MyC['data']>(key: T): MyC['data'][T] {
    return this.data[key];
  }
}

擴充套件:如果 get 支援「屬性路徑」的引數形式,如 const y = c.get('o.y'),TS 又當如何書寫呢?

備註:此處只考慮 data及深層結構均為 object 的資料格式,其他資料格式如陣列等均未考慮。

先實現 get 的傳參型別:
思路:根據物件,自頂向下找出物件的所有路徑,並返回所有路徑的聯合型別

class MyC {
  get<P extends ObjectPropName<MyC['data']>>(path: P) {
    // ... 省略 js 實現程式碼
  }
}

{
  x: number;
  o: {
    y: string
  }
}
'x' | 'o' | 'o.y'
type ObjectPropName<T, Path extends string = ''> = {
  [K in keyof T]: K extends string
    ? T[K] extends Record<string, any>
      ? ObjectPath<Path, K> | ObjectPropName<T[K], ObjectPath<Path, K>>
      : ObjectPath<Path, K>
    : Path;
}[keyof T];

type ObjectPath<Pre extends string, Curr extends string> = `${Pre extends '' 
  ? Curr
  : `${Pre}.`}${Curr}`;

再實現 get 方法的返回值型別:
思路:根據物件和路徑,自頂向下逐層驗證路徑是否存在,存在則返回路徑對應的值型別

class MyC {
  get<P extends ObjectPropName<MyC['data']>>(path: P): ObjectPropType<MyC['data'], P> {
    // ... 省略 js 實現程式碼
  }
}

type ObjectPropType<T, Path extends string> = Path extends keyof T
? T[Path]
: Path extends `${infer K}.${infer R}`
? K extends keyof T
  ? ObjectPropType<T[K], R>
  : unknown
: unknown;

Fix: Q3 獲取 dva 所有的 Actions 型別

type GenerateActions<Models extends Record<string, any>> = {
  [ModelName in keyof Models]: Models[ModelName]['reducers'] extends never
    ? never
    : {
        [ReducerName in keyof Models[ModelName]['reducers']]: Models[ModelName]['reducers'][ReducerName] extends (
          state: any,
          action: infer A,
        ) => any
          ? {
              type: `${string & ModelName}/${string & ReducerName}`;
              payload: A extends { payload: infer P } ? P : never;
            }
          : never;
      }[keyof Models[ModelName]['reducers']];
}[keyof Models];

type Actions = GenerateActions<AllModels>;

使用

// TS 報錯:不能將型別“string”分配給型別“boolean”
export const a: Actions = {
  type: 'bar/reset',
  payload: 'true',
};

// TS 報錯:不能將型別“"foo/add"”分配給型別“"bar/reset"”(此處 TS 根據 payload 為 boolean 反推的 type)
export const b: Actions = {
  type: 'foo/add',
  payload: true,
};

export const c: Actions = {
  type: 'foo/add',
  // TS 報錯:“payload1”中不存在型別“{ type: "foo/add"; payload: string; }”。是否要寫入 payload?
  payload1: true,
};

// TS 報錯:型別“"foo/add1"”不可分配給型別“"foo/add" | "bar/reset"”
export const d: Actions = {
  type: 'foo/add1',
  payload1: true,
};

繼續一連串問:

3.1 抽取 Reducer
3.2 抽取 Model
3.3 無 payload
3.4 非 payload ?
3.5 Reducer 可以不傳 State 嗎?

Fix: Q3.1 抽取 Reducer

// 備註:此處只考慮 reducer 是函式的情況,dva 中的 reducer 還可能是陣列,這種情況暫不考慮。
type Reducer<S = any, A = any> = (state: S, action: A) => S;

// foo
interface FooState {
  x: number;
}
type FooModel = {
  state: FooState;
  reducers: {
    add: Reducer<
      FooState,
      {
        payload: string;
      }
    >;
  };
};

Fix: Q3.2 抽取 Model

type Model<S = any, A = any> = {
  state: S;
  reducers: {
    [reducerName: string]: (state: S, action: A) => S;
  };
};

// foo
interface FooState {
  x: number;
}
interface FooModel extends Model {
  state: FooState;
  reducers: {
    add: Reducer<
      FooState,
      {
        payload: string;
      }
    >;
  };
}

Fix: Q3.3 無 payload ?

增加 WithoutNever,不為無 payloadaction 增加 payload 驗證。

type GenerateActions<Models extends Record<string, any>> = {
  [ModelName in keyof Models]: Models[ModelName]['reducers'] extends never
    ? never
    : {
        [ReducerName in keyof Models[ModelName]['reducers']]: Models[ModelName]['reducers'][ReducerName] extends (
          state: any,
          action: infer A,
        ) => any
          ? WithoutNever<{
              type: `${string & ModelName}/${string & ReducerName}`;
              payload: A extends { payload: infer P } ? P : never;
            }>
          : never;
      }[keyof Models[ModelName]['reducers']];
}[keyof Models];

type WithoutNever<T> = Pick<
  T,
  {
    [k in keyof T]: T[k] extends never ? never : k;
  }[keyof T]
>;

使用

interface FooModel extends Model {
  reducers: {
    del: Reducer<FooState>;
  };
}
// TS 校驗通過
const e: Actions = {
  type: 'foo/del',
};

Fix: Q3.4 非 payload ?

type GenerateActions<Models extends Record<string, any>> = {
  [ModelName in keyof Models]: Models[ModelName]['reducers'] extends never
    ? never
    : {
        [ReducerName in keyof Models[ModelName]['reducers']]: Models[ModelName]['reducers'][ReducerName] extends (
          state: any,
          action: infer A,
        ) => any
          ? A extends Record<string, any>
            ? {
                type: `${string & ModelName}/${string & ReducerName}`;
              } & {
                [K in keyof A]: A[K];
              }
            : {
                type: `${string & ModelName}/${string & ReducerName}`;
              }
          : never;
      }[keyof Models[ModelName]['reducers']];
}[keyof Models];

使用

interface FooModel extends Model {
  state: FooState;
  reducers: {
    add: Reducer<
      FooState,
      {
        x: string;
      }
    >;
  };
}
// TS 校驗通過
const f: Actions = {
  type: 'foo/add',
  x: 'true',
};

遺留 Q3.5 Reducer 可以不傳 State 嗎?

答案是肯定的,這個問題有多種思路,其中一種思路是:statereducer 都在定義的 model 上,拿到 model 後將 state 的型別注入給 reducer
這樣在定義 modelreducer 就不需手動傳 state 了。

這個問題留給大家思考和練習,此處不再展開了。

總結

extendsinfer 、 Template Literal Types 等功能非常靈活、強大,
希望大家能夠在本文的基礎上,更多的思考如何將它們運用到實踐中,減少 BUG,提升效率。

參考文章

https://www.typescriptlang.or...

https://www.typescriptlang.or...

https://www.typescriptlang.or...

https://dev.to/tipsy_dev/adva...

相關文章