iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Modern Web

我與型別的 30 天約定:TypeScript 入坑實錄系列 第 29

Day 29|型別驅動的 API 客戶端生成器:路徑、參數、回傳,一次到位

  • 分享至 

  • xImage
  •  

1) 引言:為什麼要型別驅動?

前端呼叫 API 常見痛點:

  • 路徑拼錯:/api/user vs /api/users
  • 參數型別錯:id 給了 number,但後端要 string
  • 回傳值是 any:補全不見、執行期才爆

目標

用一份「單一真相來源」的 API Schema,自動得到:

  • 型別安全的路徑與參數(params / query / body)
  • 正確的回傳型別(補全 & 編譯期檢查)
  • 一個可直接呼叫的 API Client

2) 單一真相來源:API Schema

把每條 API 的 method / path / params / query / body / response 一次說清楚。這份型別可以與後端共用(mono-repo 最讚)。

ts
CopyEdit
// api.schema.ts
export type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";

export type API = {
  getUser: {
    method: "GET";
    path: "/users/:id";
    params: { id: string };
    query?: { includePosts?: boolean };
    response: { id: string; name: string; email: string };
  };
  createUser: {
    method: "POST";
    path: "/users";
    body: { name: string; email: string };
    response: { id: string; name: string; email: string };
  };
  listPosts: {
    method: "GET";
    path: "/posts";
    query?: { authorId?: string; keyword?: string };
    response: Array<{ id: string; title: string; authorId: string }>;
  };
  publishPost: {
    method: "PATCH";
    path: "/posts/:id/publish";
    params: { id: string };
    response: { id: string; published: true };
  };
  deletePost: {
    method: "DELETE";
    path: "/posts/:id";
    params: { id: string };
    response: { id: string };
  };
};

你可以把這份 Schema 放在 packages/shared/api.schema.ts,前後端一起 import。


3) 模板字面型別 + 函式:安全替換 :params

我們需要把 "/users/:id" 變成 "/users/abc"

用一個純函式處理 runtime 替換,同時用型別確保你有把所有 params 提供齊全。

ts
CopyEdit
// url.ts
export function buildUrl(
  base: string,
  path: string,
  params?: Record<string, string>,
  query?: Record<string, unknown>
): string {
  const replaced = params
    ? path.replace(/:([A-Za-z0-9_]+)/g, (_, k) => {
        if (!(k in params)) throw new Error(`Missing param: ${k}`);
        return String(params[k]);
      })
    : path;

  const qs = query
    ? "?" +
      Object.entries(query)
        .filter(([, v]) => v !== undefined && v !== null)
        .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
        .join("&")
    : "";

  return `${base}${replaced}${qs}`;
}

這是 runtime 部分。接下來用型別把「哪些 key 必須提供」也描述清楚。


4) 條件型別:根據 method 自動推導輸入型別

不同 method 需要不同輸入(GET 沒 body、POST 有 body)。我們用條件型別把「每條 API 的輸入參數形狀」算出來。

ts
CopyEdit
// client.types.ts
export type EndpointInput<E> =
  E extends { method: "GET"; params?: infer P; query?: infer Q }
    ? { params?: P; query?: Q }
    : E extends { method: "DELETE"; params?: infer P; query?: infer Q }
      ? { params?: P; query?: Q }
      : E extends { method: "POST" | "PATCH"; params?: infer P; query?: infer Q; body?: infer B }
        ? { params?: P; query?: Q; body?: B }
        : never;

export type EndpointResponse<E> = E extends { response: infer R } ? R : never;


5) 生成型別安全的 Client:一個 call 搞定

先做一個通用 createClient,給它 baseUrl / fetch 實作與 headers。它接受「某條 API 的 spec(型別)」和輸入,就能回傳對應的型別。

ts
CopyEdit
// client.core.ts
import { buildUrl } from "./url";
import type { EndpointInput, EndpointResponse } from "./client.types";

type BaseOptions = {
  baseUrl?: string;
  fetchImpl?: typeof fetch;
  headers?: Record<string, string>;
};

export function createClient(options?: BaseOptions) {
  const baseUrl = options?.baseUrl ?? "";
  const f = options?.fetchImpl ?? fetch;
  const baseHeaders = { "Content-Type": "application/json", ...(options?.headers || {}) };

  async function call<E extends { method: string; path: string }>(
    spec: E,
    input: EndpointInput<E>
  ): Promise<EndpointResponse<E>> {
    const url = buildUrl(baseUrl, spec.path, (input as any)?.params, (input as any)?.query);

    const res = await f(url, {
      method: spec.method,
      headers: baseHeaders,
      body: spec.method === "POST" || spec.method === "PATCH"
        ? JSON.stringify((input as any)?.body ?? {})
        : undefined,
    });

    if (!res.ok) {
      // Day 20 我們會用 ApiError/Result,在這裡可丟自訂錯誤
      throw new Error(`HTTP ${res.status}`);
    }

    return res.json() as Promise<EndpointResponse<E>>;
  }

  return { call };
}


6) 把 Schema 變成具名方法(Developer Experience++)

你可能不想每次都傳一個 spec 物件。

我們可以「用型別」把 API 的 key(getUsercreateUser…)映射成對應函式簽名。

ts
CopyEdit
// app.client.ts
import type { API } from "./api.schema";
import { createClient } from "./client.core";
import type { EndpointInput, EndpointResponse } from "./client.types";

type MethodOf<A, K extends keyof A> = (input: EndpointInput<A[K]>) => Promise<EndpointResponse<A[K]>>;

type ClientOf<A> = {
  [K in keyof A]: MethodOf<A, K>;
};

export function createAppClient(api: API, opts?: { baseUrl?: string; headers?: Record<string, string> }): ClientOf<API> {
  const { call } = createClient({ baseUrl: opts?.baseUrl, headers: opts?.headers });

  // 這裡的 `api` 僅作為「值」的參考,真正的型別來自 ClientOf<API>
  return new Proxy({} as ClientOf<API>, {
    get(_, prop: string) {
      const spec = (api as any)[prop];
      if (!spec) throw new Error(`Unknown endpoint: ${prop}`);
      return (input: any) => call(spec, input);
    }
  });
}

配套一個 值層級的 schema(跟型別對應):

ts
CopyEdit
// api.spec.ts(值,用來讓 Proxy 找得到 spec)
import type { API } from "./api.schema";

export const apiSpec: API = {
  getUser:      { method: "GET",    path: "/users/:id" } as any,
  createUser:   { method: "POST",   path: "/users" } as any,
  listPosts:    { method: "GET",    path: "/posts" } as any,
  publishPost:  { method: "PATCH",  path: "/posts/:id/publish" } as any,
  deletePost:   { method: "DELETE", path: "/posts/:id" } as any,
};

這裡 as any 是為了避免把所有欄位(如 response)在值層都重抄一遍;真正的型別安全由 API 型別提供。如果想 100% 值/型別同步,可以生成程式或用 zod 建 DSL。


7) 使用方式(超順手)

ts
CopyEdit
import { createAppClient } from "./app.client";
import { apiSpec } from "./api.spec";

const client = createAppClient(apiSpec, { baseUrl: "http://localhost:3000" });

// 1) GET /users/:id
const user = await client.getUser({ params: { id: "u1" }, query: { includePosts: true } });
user.id;      // string 補全
user.email;   // string 補全
// user.unknown; // ❌ 報錯:不存在

// 2) POST /users
const created = await client.createUser({ body: { name: "Alice", email: "a@a.com" } });
// client.createUser({ body: { name: 123 } }) // ❌ name 應為 string

// 3) GET /posts
const posts = await client.listPosts({ query: { keyword: "ts" } });
posts[0].title; // 補全


8) 端到端更穩:接上 zod 驗證與 Result 錯誤流(Day 20 串接)

把回傳資料做 schema 驗證,並以 Result 表示成功/失敗,不必 everywhere try/catch。

ts
CopyEdit
// result.ts
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

// error.ts
export type ApiError = { code: string; message: string; details?: unknown };

// schemas.ts
import { z } from "zod";
export const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});
export type User = z.infer<typeof userSchema>;

改寫 call 增加驗證(可針對不同 endpoint 傳不同 schema):

ts
CopyEdit
// client.with-validate.ts
import { z } from "zod";
import { createClient } from "./client.core";
import type { Result } from "./result";
import type { ApiError } from "./error";

export function createValidatedClient() {
  const { call } = createClient();

  async function callValidated<E extends { method: string; path: string }, T>(
    spec: E,
    input: any,
    schema: z.ZodSchema<T>
  ): Promise<Result<T, ApiError>> {
    try {
      const data = await call(spec, input);
      const parsed = schema.parse(data);
      return { ok: true, value: parsed };
    } catch (e: any) {
      return { ok: false, error: { code: "API_ERROR", message: String(e?.message ?? e) } };
    }
  }

  return { callValidated };
}

使用:

ts
CopyEdit
import { createValidatedClient } from "./client.with-validate";
import { apiSpec } from "./api.spec";
import { userSchema } from "./schemas";

const vc = createValidatedClient();

const r = await vc.callValidated(apiSpec.getUser, { params: { id: "u1" } }, userSchema);

if (r.ok) {
  r.value.email; // 補全
} else {
  console.error(r.error.code, r.error.message);
}


9) 進階技巧與最佳實踐

  • 把 Schema 放在 shared 套件:前後端共用,避免型別飄移
  • 用模板字面型別產路徑常量type Paths = /users/${string}`` 搭配 keyof 管理
  • 以 DSL/程式碼產生器維持值/型別一致:減少 as any(如用 zod/valibot 定義,輸出 TS 型別)
  • 搭配拋錯策略:在 call 層把 HTTP 狀態碼轉成 ApiError,前端統一處理(Day 20)
  • 開發 vs 生產:開發階段可開 logvalidate;生產縮減驗證開銷(只在重要路徑驗證)

10) 常見坑 & 解法

  1. params 少給 / 名稱不對
  • 型別層用 params?: infer P + 函式層 Missing param: ... 雙保險。
  1. body 與後端不一致
  • zod 在 client 驗證;或直接共用 zod schema(以 z.infer 生成型別)。
  1. 回傳型別 any 滲入
  • 永遠讓 call 回傳 Promise<EndpointResponse<E>>,不要裸 any
  1. 方法很多,手動綁太累
  • Proxy(上面)、或寫一個 map 函數把 API key 自動轉成方法。

上一篇
Day 28|型別安全的事件系統實戰
下一篇
Day 30|總結與下一步:把 TypeScript 變成你的團隊超能力
系列文
我與型別的 30 天約定:TypeScript 入坑實錄30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言