使用 TypeScript 定義業務字典

雲音樂技術團隊發表於2023-01-17
本文作者:htl

業務字典

在業務開發中,我們常常需要定義一些列舉值。假設我們正在開發一款音樂應用,我們需要定義音樂的型別,以便在業務程式碼中進行業務邏輯判斷:

const MUSIC_TYPE = {
  POP: 1,
  ROCK: 2,
  RAP: 3,
  // ...
};

if (data.type === MUSIC_TYPE.POP) {
  // 當音樂型別為流行音樂時,執行某些邏輯
}

隨著業務邏輯的擴充套件,簡單的列舉值往往會衍生出許多關聯的字典。比如,我們需要定義一個音樂的型別對應的名稱

const MUSIC_TYPE_NAMES = {
  [MUSIC_TYPE.POP]: '流行音樂',
  [MUSIC_TYPE.ROCK]: '搖滾音樂',
  [MUSIC_TYPE.RAP]: '說唱音樂',
  // ...
};

// 展示音樂型別名稱
<div>{MUSIC_TYPE_NAMES[data.type]}</div>

或者需要定義一個音樂型別對應的圖示:

const MUSIC_TYPE_ICONS = {
  [MUSIC_TYPE.POP]: 'pop.svg',
  [MUSIC_TYPE.ROCK]: 'rock.svg',
  [MUSIC_TYPE.RAP]: 'rap.svg',
  // ...
};

// 展示音樂型別圖示
<img src={MUSIC_TYPE_ICONS[data.type]} />

在列表場景下,我們可能需要定義一個陣列形式的字典:

const MUSIC_TYPE_LIST = [
  {
    type: MUSIC_TYPE.POP,
    name: '流行音樂',
    icon: 'pop.svg',
  },
  {
    type: MUSIC_TYPE.ROCK,
    name: '搖滾音樂',
    icon: 'rock.svg',
  },
  {
    type: MUSIC_TYPE.RAP,
    name: '說唱音樂',
    icon: 'rap.svg',
  },
  // ...
];

<div>
  {MUSIC_TYPE_LIST.map((item) => (
    <div>
      <img src={item.icon} />
      <span>{item.name}</span>
    </div>
  ))}
</div>;

又或者希望使用 key-object 形式避免從多個字典取值:

const MUSIC_TYPE_MAP_BY_VALUE = {
  [MUSIC_TYPE.POP]: {
    name: '流行音樂',
    icon: 'pop.svg',
  },
  [MUSIC_TYPE.ROCK]: {
    name: '搖滾音樂',
    icon: 'rock.svg',
  },
  [MUSIC_TYPE.RAP]: {
    name: '說唱音樂',
    icon: 'rap.svg',
  },
  // ...
};

const musicTypeInfo = MUSIC_TYPE_MAP_BY_VALUE[data.type];

<div>{musicTypeInfo.name}:{musicTypeInfo.icon}</div>;

這些形態各異的業務字典同時存在會給程式碼帶來重複和混亂。

當我們需要變更或增刪某個型別或者型別中的某個值時,需要同時修改多個字典,很容易出現遺漏和錯誤,尤其是當這些字典定義分佈在不同的檔案中。

對於使用者來說,散亂的字典定義也是一種負擔。在業務中使用某個字典時,需要先查詢已有的字典並理解其定義。如果已有字典不能完全滿足需求,可能會有新的字典被定義,進一步增加業務字典的混亂程度。

字典工廠函式

我們可以實現一個工具函式,將一份定義轉換成多種格式的字典。

首先考慮入參的格式。顯然作為原始資料,入參必須能夠包含完整的字典資訊,包括鍵,值,所有擴充套件欄位,甚至列表場景中的展示順序。

我們可以使用物件陣列作為入參:

/**
 * list 示例:
 * [
 *   {
 *    key: 'POP',
 *    value: 1,
 *    name: '流行音樂',
 *   },
 *   {
 *     key: 'ROCK',
 *     value: 2,
 *     name: '搖滾音樂',
 *   },
 *   // ...
 * ]
 */
function defineConstants(list) {
  // ...
}

接下來考慮出參的格式。出參應該是一個物件,包含多種格式的字典:

const { KV, VK, LIST, MAP_BY_KEY, MAP_BY_VALUE } = defineConstants([
  {
    key: 'POP',
    value: 1,
    name: '流行音樂',
  },
  {
    key: 'ROCK',
    value: 2,
    name: '搖滾音樂',
  },
  // ...
]);

KV; // { POP: 1, ROCK: 2, ... }
VK; // { 1: 'POP', 2: 'ROCK', ... }
LIST; // [{ key: 'POP', value: 1, name: '流行音樂' }, { key: 'ROCK', value: 2, name: '搖滾音樂' }, ...]
MAP_BY_KEY; // { POP: { key: 'POP', value: 1, name: '流行音樂' }, ROCK: { key: 'ROCK', value: 2, name: '搖滾音樂' }, ... }
MAP_BY_VALUE; // { 1: { key: 'POP', value: 1, name: '流行音樂' }, 2: { key: 'ROCK', value: 2, name: '搖滾音樂' }, ... }

在實際業務中,我們會為不同的資源定義字典,因此我們需要為工具函式提供名稱空間。使用第二個入參為出參中的 key 增加字首:

const {
  MUSIC_TYPE_KV,
  MUSIC_TYPE_VK,
  MUSIC_TYPE_LIST,
  MUSIC_TYPE_MAP_BY_KEY,
  MUSIC_TYPE_MAP_BY_VALUE,
} = defineConstants(
  [
    {
      key: 'POP',
      value: 1,
      name: '流行音樂',
    },
    {
      key: 'ROCK',
      value: 2,
      name: '搖滾音樂',
    },
    // ...
  ],
  'MUSIC_TYPE',
);

至此,我們完成了字典工廠函式的設計。這個函式的 JavaScript 實現並不複雜,你可能已經在一些專案中過見過類似的工具函式,但是實際使用時會發現一個問題。

使用 TypeScript 實現型別提示

使用字典工廠定義業務字典可以讓程式碼更簡潔並且規範字典資料格式。然而,相比直接定義,字典工廠的缺點是無法提供型別提示。

這給開發者在兩個層面帶來了不便,一是在定義字典時需要對工具函式的使用和實現有一定了解,這樣才能正確傳入引數和解構返回值;二是在使用字典時無法獲得型別提示,使用字典的開發者需要回來檢視定義了哪些欄位和值,同時還需要了解工具函式的使用方式。

為了解決這個問題,我們可以使用 TypeScript 來實現字典工廠函式。以下內容涉及 TypeScript 型別系統的一些特性和一些技巧。

LIST 字典的實現

首先實現最簡單的 LIST 字典,因為它和入參一模一樣:

interface IBaseDef {
  key: PropertyKey;
  value: string | number;
}

function defineConstants<T extends IBaseDef[], N extends string>(
  defs: T,
  namespace?: N,
) {
  const prefix = namespace ? `${namespace}_` : '';
  return {
    [`${prefix}LIST`]: defs,
  };
}

我們用 IBaseDef 來規範入參中字典項的型別,它包含 keyvalue 兩個欄位。key 的型別是 PropertyKey,它是 string | number | symbol 的聯合型別,即 key 的值可以是這三種型別中的任意一種。value 的型別是 string | number,之所以沒有 symbol 是因為業務中 value 的值可能會從外部獲取,而 key 的值可以是執行時產生的。這兩個欄位是定義字典必須的,其他欄位可以根據業務需要任意新增。

defineConstants 函式中,我們使用範型來分別表示兩個入參的型別並且使用 extends 關鍵字來約束範型的型別。T 的型別是 IBaseDef[],保證入參 defs 的格式符合字典項陣列。N 的型別是 string,保證入參 namespace 是一個字串。

namespace 引數是可選的,如果定義字典時未傳入,那麼返回的字典 Key 也不會有字首。因此我們需要建立一個 prefix 變數並根據 namespace 是否存在來決定它的值。

然後我們返回一個只有 LIST 字典的物件,它的 Key 由 prefixLIST 拼接而成,值就是入參 defs

這段程式碼的執行邏輯沒有問題,但是它缺少了返回值的型別定義,透過 IDE 的程式碼提示並不能獲取到正確的字典 key:

當你在 IDE 中檢視 dicts 的型別時,IDE 並不會真的去執行 JavaScript 程式碼,而是透過 TypeScript 的型別系統來生成型別。

因此,我們需要使用型別系統定義 defineConstants 的返回型別。

type ToProperty<
  Property extends string,
  N extends string = '',
> = N extends '' ? Property : `${N}_${Property}`;

這裡我們定義了一個型別用於生成字典的 Key。它接收兩個範型引數,Property 表示字典的屬性,N 表示字典的名稱空間。如果 N 為空字串,那麼返回的 Key 就是 Property,否則就是 ${N}_${Property}

這段程式碼中有一些 JavaScript 語法的影子,比如字串,預設引數值,三元運算子,模板字串等。但是這些都是在 TypeScript 型別系統中執行的,可以看作是一套獨立的語言。例如它並沒有 if...else 語句,這裡的三元運算實際是條件型別(Conditional Types)的語法,當 N 的型別符合 '' 時,返回 Property,否則返回 ${N}_${Property}

你可以把這樣的型別定義看作型別系統中的「函式」。不同於 JavaScript 函式透過入參接收值並且返回新的值,它透過範型接收型別並且返回新的型別。

現在我們可以使用 ToProperty 來生成字典的 Key 的型別:

接下來使用 ToProperty 結合對映型別 (Mapped Types)型別斷言(Type Assertions)指定 defineConstants 的返回型別:

function defineConstants<T extends IBaseDef[], N extends string>(
  defs: T,
  namespace?: N,
) {
  const prefix = namespace ? `${namespace}_` : '';
  return {
    [`${prefix}LIST`]: defs,
  } as {
    [Key in ToProperty<'LIST', N>]: T;
  };
}

as 關鍵字在型別系統中表示型別斷言,是一種手動指定型別的方法。它允許你告訴編譯器一個變數或值的型別是什麼,而不是讓編譯器自動推斷。

而型別對映是一種將已有型別轉換為具有指定鍵值的新型別的方法。我們生成了一個新的物件型別,它的鍵是 ToProperty<'LIST', N>,值是 T

將這些結合起來,defineConstants 函式終於可以返回一個支援型別提示的字典了:

KV 字典的實現

接下來增加 KV 字典,它是一個鍵值對,鍵和值分別來自入參字典項中的 keyvalue 屬性。

function defineConstants<T extends readonly IBaseDef[], N extends string>(
  defs: T,
  namespace?: N,
) {
  const prefix = namespace ? `${namespace}_` : '';
  return {
    [`${prefix}LIST`]: defs,
    [`${prefix}KV`]: defs.reduce(
      (map, item) => ({
        ...map,
        [item.key]: item.value,
      }),
      {},
    ),
  } as MergeIntersection<
    {
      [Key in ToProperty<'LIST', N>]: T;
    } & {
      [Key in ToProperty<'KV', N>]: {
        [Key in ToProperty<'KV', N>]: ToKeyValue<T>;
      };
    }
  >;
}

這段程式碼增加了MergeIntersectionToSingleKeyValueToKeyValue 三個型別轉換「函式」,並且將範型 T 進一步約束為 readonly。接下來將一一解釋這些型別轉換的作用和實現以及為什麼 T 必須是 readonly。

MergeIntersection 用於合併交叉型別。

由於我們的實現中不同字典型別是透過對映型別生成的,我們需要使用交叉型別(Intersection Types)將它們合併,當合並多個型別後會變得難以閱讀。

使用 MergeIntersection 可以將交叉型別合併為一個型別,在視覺上更加清晰,也便於後續處理:

MergeIntersection 的實現:

type MergeIntersection<A> = A extends infer T
  ? { [Key in keyof T]: T[Key] }
  : never;

這裡我們再次使用了條件型別和對映型別。而 infer 關鍵字則是型別推斷(Type Inference)的語法,它可以讓我們在條件型別中獲取型別變數的具體型別並用於後續的對映型別。

由於 infer 總能推斷出一個型別,所以條件型別的第二個結果永遠不會出現,因此我們可以使用 never 型別。

ToSingleKeyValue 用於將單個字典項轉換為鍵值對:

ToSingleKeyValue 的實現:

type ToSingleKeyValue<T> = T extends {
  readonly key: infer K;
  readonly value: infer V;
}
  ? K extends PropertyKey
    ? {
        [Key in K]: V;
      }
    : never
  : never;

我們使用 infer 關鍵字獲取 keyvalue 的具體型別並且在一個條件型別使用他們。然後在第二個條件型別中明確 key 的型別是 PropertyKey,因此可以用於對映型別。最後指定對映型別中的鍵和值。

ToKeyValue 用於將字典項陣列轉換為鍵值對:

ToKeyValue 的實現:

type ToKeyValue<T> = T extends readonly [infer A, ...infer B]
  ? B['length'] extends 0
    ? ToSingleKeyValue<A>
    : MergeIntersection<ToSingleKeyValue<A> & ToKeyValue<B>>
  : [];

這個實現的關鍵點是使用型別推斷結合展開語法和遞迴特性實現陣列型別的處理。

我們在第一個條件型別中獲取陣列的第一個元素和剩餘元素,然後在第二個條件型別中判斷剩餘元素的長度是否為 0。如果為 0,說明陣列只有一個元素,我們可以直接使用 ToSingleKeyValue進行型別轉換。否則轉換第一個元素並遞迴使用 ToKeyValue 轉換剩餘部分,最後使用 MergeIntersection 將結果合併。

defineConstants 和這些型別轉換函式中使用了 readonly 關鍵字,這實際上源於 defineConstants 的一個使用限制:在使用 defineConstants 時,必須使用 [const 斷言(const
assertions)](https://www.typescriptlang.or...),即在字典項陣列後面加上 as const

defineConstants([
  {
    key: 'POP',
    value: 1,
    name: '流行音樂',
  },
  {
    key: 'ROCK',
    value: 2,
    name: '搖滾音樂',
  },
] as const, 'MUSIC_TYPE');

對於程式碼中的常量定義,TypeScript 會自動推斷變數型別而抹去具體的值。這在通常情況下是合理的,但是對於 defineConstants 型別提示的實現是很大的阻礙。如果入參字典項中的值資訊丟失,我們也就無法透過型別系統進行型別轉換生成字典的型別定義。

對比是否使用 as const 的區別:

而使用 const 斷言同時也會將字典項的屬性在型別系統中變成只讀,這也是我們在函式中使用 readonly 關鍵字的原因。

以上內容基本上覆蓋了剩餘字典型別轉換所需的全部語法和技巧,例如 VK 格式只是將鍵值對換,MAP_BY_KEY 只是將值替換為字典項的型別,因此不再贅述。完整的實現可以在Github Gist獲取,也可以直接在這個CodeSandbox 示例中嘗試使用效果。

至此我們已經使用 TypeScript 實現了可以生成帶有支援型別提示的業務字典工廠函式,透過這個函式定義和使用業務字典可以在各處獲取型別提示。

定義字典時:

使用字典時:

缺陷和不足

這個工具給作者本人在專案中帶來很大的幫助,但還是存在一些缺陷和不足:

  1. 只能在 TypeScript 專案中使用,並且在定義字典時需要使用 as const 關鍵字。

通常來說一個工具函式以 TypeScript 實現,只要提供良好的型別定義就可以在 JavaScript 專案中方便地使用。

但是由於 JavaScript 無法支援 const 斷言或類似功能,這個工具只能在 TypeScript 中使用。

  1. 使用者無法在型別提示中獲取註釋

當我們定義一個列舉值時,可能會增加一些註釋:

enum MusicTypes {
  /**
   * 流行
   */
  POP: 1,
}

開發者在使用這個列舉值時,可以透過 IDE 獲取註釋內容。然而透過字典工廠函式生成的字典經過轉換已經丟失了這些資訊。

  1. 無法同時匯出型別定義

defineConstants 返回的是字典值,當下遊需要引用字典型別時,還需要需要額外匯出型別定義:

export const { MUSIC_TYPE_VALUES } = defineConstants([...], 'MUSIC_TYPE')

// 匯出字典型別
export type MUSIC_TYPE = MUSIC_TYPE_VALUES[number]

// 下游型別定義
import { MUSIC_TYPE } from './constants'

interface Music {
  type: MUSIC_TYPE;
  // ...
}

總結

本文針對業務字典定義的場景,使用 TypeScript 實現了一個工具函式,用於生成各種形式且帶有型別提示的業務字典。同時指出了這個工具函式的一些使用限制和不足之處。

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章