Hyoban

Hyoban

Don’t do what you should do, do you want.
x
github
email
telegram
follow

如何讓 fetch 變得類型安全

前言#

fetch("https://jsonplaceholder.typicode.com/todos/1")
  .then((response) => response.json())
  .then((json) => console.log(json));

當你直接使用如上的代碼來發起網路請求時,你會發現你的 IDE 無法給你提供任何關於 json 的類型提示。這很合理,沒人知道這個介面會返回什麼格式的資料。於是你需要進行如下幾個步驟:

  1. 手動測試這個介面,獲取返回值
  2. 根據返回值的內容,利用如 transform.tools 之類的工具生成對應的類型定義
  3. 將類型定義複製到你的專案中
  4. 使用 as 來標識返回值的類型

這樣下來你也只是能獲得介面正常返回時 json 的類型而已,還有很多問題需要被解決:

  1. 手動請求得到返回值不夠準確,比如調用介面出錯時,返回值的類型可能會發生變化
  2. 請求的 url 和輸入的參數沒有任何類型提示
  3. 當後端介面變動時,tsc 無法給出報錯

解決方案的基礎#

ts 中有個非常好用的功能是 as const 斷言,給變數添加這個斷言後,變數的類型會被推斷為更具體的字面量類型,同時會禁止對變數的修改。下面是一個簡單的例子,可以看到 navigateTo 這個函數的參數變得更加安全了,我們不能傳遞一個未被定義的路由名稱字串。

const routes = {
  home: "/",
  about: "/about",
} as const;

declare function navigateTo(route: keyof typeof routes): void;

navigateTo("404");
// Argument of type '"404"' is not assignable to parameter of type '"home" | "about"'.

這就是我們能實現類型安全的 fetch 的基礎。

定義路由結構#

首先我們需要定義一個路由結構,這個結構包含了一個介面的全部必要資訊(請求路徑,輸入,輸出,以及可能出現的錯誤碼)。同時這裡我們使用 zod 來進行運行時的資料格式校驗。

// add.ts
import { z } from "zod";

export const path = "/add" as const;

export const input = z.object({
  a: z.number(),
  b: z.number(),
});

export const data = z.object({
  result: z.number(),
});

export const errCode = ["NO_LOGIN"] as const;

定義多個介面之後,在 index.ts 中導出所有的介面。

// index.ts
export * as Add from "./add";

然後我們就能拿到全部的路由資訊

import * as routesWithoutPrefixObj from "./interface/index";

const routesWithoutPrefix = Object.values(
  routesWithoutPrefixObj
) as ValueOfObjectArray<typeof routesWithoutPrefixObj>;

定義公共類型#

定義通用返回結構,公共前綴和未知錯誤碼。我們並不是介面直接返回資料,而是返回一個包含了錯誤碼和資料的物件。

const routesWithoutPrefix = Object.values(
  routesWithoutPrefixObj
) as ValueOfObjectArray<typeof routesWithoutPrefixObj>;

export const prefix = "/api";
export type Prefix = typeof prefix;
export const unknownError = "UNKNOWN_ERROR" as const;

export type OutputType<T, Err extends readonly string[]> =
  | {
      err: ArrayToUnion<Err> | typeof unknownError;
      data: null;
    }
  | {
      err: null;
      data: T;
    };

計算出帶前綴的實際路由

export const routes = routesWithoutPrefix.map((r) => {
  return {
    ...r,
    path: `${prefix}${r.path}`,
  };
}) as unknown as AddPathPrefixForRoutes<typeof routesWithoutPrefix>;

轉換路由結構#

到此為止,我們只是拿到了全部路由物件組成的陣列,這對於我們實現類型安全的 fetch 來說並不方便。我們需要將這個陣列轉換成一個物件,這個物件的 key 是路由的請求路徑,value 是路由的其它資訊。如此,調用 fetch 時,我們就能獲得更好的補全體驗,在輸入確定的路徑之後,能得到正確的輸入類型和輸出類型。

type RouteItem = {
  readonly path: string;
  input: z.AnyZodObject;
  data: z.AnyZodObject;
  errCode: readonly string[];
};

type Helper<T> = T extends RouteItem
  ? Record<
      T["path"],
      {
        input: z.infer<T["input"]>;
        data: z.infer<T["data"]>;
        errCode: T["errCode"];
        output: OutputType<z.infer<T["data"]>, T["errCode"]>;
      }
    >
  : never;

export type DistributedHelper<T> = T extends RouteItem ? Helper<T> : never;

export type Route = UnionToIntersection<
  DistributedHelper<ArrayToUnion<typeof routes>>
>;

如此我們就能得到最終包含全部路由資訊的類型 Route,這個類型的大致結構如下:

type Route = Record<"/api/add", {
    input: {
        a: number;
        b: number;
    };
    data: {
        result: number;
    };
    errCode: readonly ["NO_LOGIN"];
    output: OutputType<{
        result: number;
    }, readonly ["NO_LOGIN"]>;
}> & Record<"/api/dataSet/delDataSet", {
    ...;
}> & ... 13 more ... & Record<...>

為 fetch 添加類型#

可以看到,只需要約束 fetch 的第一個參數 path 為 keyof Route,第二個參數為對應路由的輸入類型,就能得到正確的輸出類型。為了實現上的簡單,所有的 http 請求都使用 POST 方法,所有的輸入參數都從 body 中傳遞。

export const myFetch = async <Path extends keyof Route>(
  path: Path,
  input: Route[Path]["input"]
) => {
  try {
    const res = await fetch(path, {
      method: "POST",
      headers: headers,
      body: input instanceof FormData ? input : JSON.stringify(input),
    });
    const data = await (res.json() as Promise<Route[Path]["output"]>);
    if (data.err) {
      throw new CustomError(data.err);
    }
    return data.data as Route[Path]["data"];
  } catch (err) {
    if (err instanceof CustomError) {
      throw err;
    }

    if (err instanceof Error) {
      throw new CustomError(err.message);
    }

    throw new CustomError(unknownError);
  }
};

class CustomError<T extends string> extends Error {
  errorCode: T;

  constructor(msg: string) {
    super(msg);
    Object.setPrototypeOf(this, CustomError.prototype);
    this.errorCode = msg as T;
  }
}

nice,讓我們來看看使用效果吧。

ScreenShot 2023-08-09 12.38.53.gif

最後#

文章寫到這裡就算是結束了,取決你自己的需求,你還可以做如下事情:

  1. 將它和 useSWR 組合起來,這樣你就不必為每個網路介面單獨封裝函數了
  2. 在 fetch 中封裝更多邏輯,比如自動攜帶登入所需的 token 和在登入狀態過期時自動的路由攔截
  3. 單獨處理非 json 交互的介面,比如檔案上傳下載
  4. 說服你的後端,讓它用 ts 來給你寫介面,這樣這些路由定義就不需要你手動寫了,也更安全

如果你感興趣,以下是一些被用到但是沒有在文章中展開的類型計算。當然,這份實現未必是最好的,如果你有任何改進的想法,歡迎與我討論,非常感謝。

export type ValueOfObjectArray<
  T,
  RestKey extends unknown[] = UnionToTuple<keyof T>
> = RestKey extends []
  ? []
  : RestKey extends [infer First, ...infer Rest]
  ? First extends keyof T
    ? [T[First], ...ValueOfObjectArray<T, Rest>]
    : never
  : never;

// https://github.com/type-challenges/type-challenges/issues/2835
type LastUnion<T> = UnionToIntersection<
  T extends any ? (x: T) => any : never
> extends (x: infer L) => any
  ? L
  : never;

type UnionToTuple<T, Last = LastUnion<T>> = [T] extends [never]
  ? []
  : [...UnionToTuple<Exclude<T, Last>>, Last];

type AddPrefix<T, P extends string = ""> = T extends RouteItem
  ? {
      path: `${P}${T["path"]}`;
      input: T["input"];
      data: T["data"];
      errCode: T["errCode"];
    }
  : never;

export type AddPrefixForArray<Arr> = Arr extends readonly []
  ? []
  : Arr extends readonly [infer A, ...infer B]
  ? [AddPrefix<A, Prefix>, ...AddPrefixForArray<B>]
  : never;

export type DistributedHelper<T> = T extends RouteItem ? Helper<T> : never;

export type ArrayToUnion<T extends readonly any[]> = T[number];

export type UnionToIntersection<U> = (
  U extends any ? (k: U) => void : never
) extends (k: infer I) => void
  ? I
  : never;
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。