跳至主要內容
Skip to content

前後端類型共享策略

前後端分離時,如何確保 API 類型一致?本篇將介紹從簡單到進階的類型共享策略。


一、 為什麼要共享類型?

1.1 問題場景

typescript
// 後端定義
interface User {
  id: number;
  name: string;
  email: string;
}

// 前端定義(可能不一致!)
interface User {
  id: number;
  name: string;
  mail: string; // 欄位名稱錯誤
}

1.2 共享類型的好處

  • 減少錯誤:前後端使用相同的類型定義
  • 提升效率:不需要重複定義
  • 自動同步:類型變更自動反映到兩端
  • 更好的 IDE 支援:自動完成、錯誤檢查

二、 策略一:共享類型檔案

2.1 專案結構

project/
├── packages/
│   ├── shared/          # 共享類型
│   │   ├── package.json
│   │   └── src/
│   │       └── types.ts
│   ├── backend/         # 後端專案
│   │   └── package.json
│   └── frontend/        # 前端專案
│       └── package.json
└── package.json         # 根 package.json

2.2 設定 Monorepo

package.json(根目錄):

json
{
  'private': true,
  'workspaces': ['packages/*']
}

packages/shared/package.json:

json
{
  'name': '@myapp/shared',
  'version': '1.0.0',
  'main': 'src/index.ts',
  'types': 'src/index.ts'
}

2.3 定義共享類型

typescript
// packages/shared/src/types.ts
export interface User {
  id: number;
  name: string;
  email: string;
  createdAt: string;
}

export interface CreateUserInput {
  name: string;
  email: string;
  password: string;
}

export interface ApiResponse<T> {
  success: boolean;
  data: T;
  message?: string;
}

export type UserResponse = ApiResponse<User>;
export type UserListResponse = ApiResponse<User[]>;

2.4 使用

typescript
// packages/backend/src/controllers/user.ts
import { User, CreateUserInput, UserResponse } from '@myapp/shared';

export async function createUser(input: CreateUserInput): Promise<User> {
  // ...
}

// packages/frontend/src/api/user.ts
import { User, CreateUserInput, UserResponse } from '@myapp/shared';

export async function createUser(input: CreateUserInput): Promise<User> {
  const response = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(input),
  });
  const result: UserResponse = await response.json();
  return result.data;
}

三、 策略二:使用 Zod 共享

3.1 定義 Schema

typescript
// packages/shared/src/schemas/user.ts
import { z } from 'zod';

export const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

export const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  password: z.string().min(8),
});

// 匯出類型
export type User = z.infer<typeof userSchema>;
export type CreateUserInput = z.infer<typeof createUserSchema>;

3.2 後端驗證

typescript
// packages/backend/src/routes/user.ts
import { createUserSchema, userSchema } from '@myapp/shared';

app.post('/users', (req, res) => {
  const result = createUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten() });
  }

  const user = await createUser(result.data);

  // 確保回應符合 schema
  res.json(userSchema.parse(user));
});

3.3 前端驗證

typescript
// packages/frontend/src/api/user.ts
import {
  createUserSchema,
  userSchema,
  User,
  CreateUserInput,
} from '@myapp/shared';

export async function createUser(input: CreateUserInput): Promise<User> {
  // 發送前驗證
  const validated = createUserSchema.parse(input);

  const response = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(validated),
  });

  const data = await response.json();

  // 回應驗證
  return userSchema.parse(data);
}

四、 策略三:tRPC

4.1 什麼是 tRPC?

tRPC 是一個端到端類型安全的 RPC(Remote Procedure Call)框架,讓前端直接呼叫後端函式,自動獲得類型。

4.2 後端設定

typescript
// packages/backend/src/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;
typescript
// packages/backend/src/routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';

export const userRouter = router({
  getAll: publicProcedure.query(async () => {
    return await db.user.findMany();
  }),

  getById: publicProcedure
    .input(z.object({ id: z.number() }))
    .query(async ({ input }) => {
      return await db.user.findUnique({ where: { id: input.id } });
    }),

  create: publicProcedure
    .input(
      z.object({
        name: z.string(),
        email: z.string().email(),
      })
    )
    .mutation(async ({ input }) => {
      return await db.user.create({ data: input });
    }),
});
typescript
// packages/backend/src/server.ts
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { userRouter } from './routers/user';

const appRouter = router({
  user: userRouter,
});

export type AppRouter = typeof appRouter;

const server = createHTTPServer({
  router: appRouter,
});

server.listen(3000);

4.3 前端使用

typescript
// packages/frontend/src/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@myapp/backend';

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000',
    }),
  ],
});
typescript
// packages/frontend/src/pages/Users.vue
<script setup lang='ts'>
import { trpc } from '../trpc'

// 完全類型安全!
const users = await trpc.user.getAll.query()
// users 自動推論為 User[]

const user = await trpc.user.getById.query({ id: 1 })
// user 自動推論為 User | null

const newUser = await trpc.user.create.mutate({
  name: 'John',
  email: 'john@example.com'
})
// newUser 自動推論為 User
</script>

五、 策略四:OpenAPI/Swagger 生成

5.1 從 OpenAPI 生成類型

bash
npm install -D openapi-typescript
bash
npx openapi-typescript http://localhost:3000/api-docs/swagger.json -o src/types/api.ts

5.2 生成的類型

typescript
// 自動生成
export interface paths {
  '/users': {
    get: {
      responses: {
        200: {
          content: {
            'application/json': components['schemas']['UserList'];
          };
        };
      };
    };
    post: {
      requestBody: {
        content: {
          'application/json': components['schemas']['CreateUser'];
        };
      };
    };
  };
}

export interface components {
  schemas: {
    User: {
      id: number;
      name: string;
      email: string;
    };
  };
}

5.3 使用生成的類型

typescript
import { paths, components } from './types/api';

type User = components['schemas']['User'];
type CreateUserInput =
  paths['/users']['post']['requestBody']['content']['application/json'];

六、 策略比較

策略複雜度即時類型執行驗證適用場景
共享類型檔案小型專案
Zod 共享中型專案
tRPC全棧專案
OpenAPI 生成需重新生成視後端多團隊

七、 最佳實踐

7.1 類型組織

typescript
// packages/shared/src/index.ts

// 實體類型
export * from './entities/user';
export * from './entities/post';

// API 類型
export * from './api/requests';
export * from './api/responses';

// Schema(如果使用 Zod)
export * from './schemas/user';
export * from './schemas/post';

7.2 版本管理

typescript
// 使用版本化的 API 類型
export namespace V1 {
  export interface User {
    id: number;
    name: string;
  }
}

export namespace V2 {
  export interface User {
    id: number;
    firstName: string;
    lastName: string;
  }
}

總結

策略優點缺點
共享類型簡單直接無執行驗證
Zod編譯+執行驗證需額外依賴
tRPC完整類型安全需要改架構
OpenAPI語言無關需維護文件

> **選擇建議**:

  • 小型專案:共享類型檔案
  • 需要驗證:Zod 共享
  • 全棧 TypeScript:tRPC
  • 多語言/多團隊:OpenAPI

進階挑戰

  1. 設定一個 pnpm workspace monorepo
  2. 使用 tRPC 建立一個完整的 CRUD API
  3. 從 OpenAPI 規格生成前端 API 客戶端

延伸閱讀與資源