前端呼叫 API 常見痛點:
/api/user
vs /api/users
id
給了 number,但後端要 stringany
:補全不見、執行期才爆目標:
用一份「單一真相來源」的 API Schema,自動得到:
- 型別安全的路徑與參數(params / query / body)
- 正確的回傳型別(補全 & 編譯期檢查)
- 一個可直接呼叫的 API Client
把每條 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。
: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 必須提供」也描述清楚。
不同 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;
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 };
}
你可能不想每次都傳一個 spec 物件。
我們可以「用型別」把 API
的 key(getUser
、createUser
…)映射成對應函式簽名。
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。
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; // 補全
把回傳資料做 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);
}
type Paths =
/users/${string}`` 搭配 keyof
管理as any
(如用 zod
/valibot
定義,輸出 TS 型別)call
層把 HTTP 狀態碼轉成 ApiError
,前端統一處理(Day 20)log
、validate
;生產縮減驗證開銷(只在重要路徑驗證)params?: infer P
+ 函式層 Missing param: ...
雙保險。zod
在 client 驗證;或直接共用 zod
schema(以 z.infer
生成型別)。any
滲入
call
回傳 Promise<EndpointResponse<E>>
,不要裸 any
。Proxy
(上面)、或寫一個 map 函數把 API
key 自動轉成方法。