本文 AI 產出,尚未審核

TypeScript 實務開發與架構應用 – FormData 型別安全處理


簡介

在前端開發中,FormData 是與後端傳遞檔案、表單資料的常見工具。它可以直接把 <input type="file">、文字欄位等包裝成 multipart/form-data,讓 fetchaxios 等 HTTP 客戶端無縫上傳。然而,原生的 FormData 完全是「字串」與「Blob」的集合,缺乏型別資訊,導致在 TypeScript 專案中很容易寫出錯誤卻不被編譯器捕捉。

本文將說明如何在 TypeScript 中為 FormData 加上型別安全的包裝,讓開發者在編寫上傳程式碼時,能夠:

  1. 在編譯階段就捕捉欄位名稱拼寫錯誤
  2. 保證必填欄位一定被設定
  3. 提供自動完成與文件提示,提升開發效率

透過實作範例、常見陷阱與最佳實踐,帶你把「隨意」的 FormData 轉變為「可預測」的型別安全工具。


核心概念

1. 為 FormData 定義介面 (Interface)

最直接的做法是先為預期的表單欄位建立一個介面,描述每個欄位的型別與是否必要。

// 定義上傳使用者資料的表單結構
interface UserProfileForm {
  /** 使用者名稱 (必填) */
  username: string;
  /** 電子郵件 (必填) */
  email: string;
  /** 大頭貼圖片 (可選) */
  avatar?: File;               // 只接受 File/Blob
  /** 年齡,若未提供則視為 undefined */
  age?: number;
}

重點:介面只描述「資料的形狀」,不會改變 FormData 本身的行為。

2. 建立型別安全的 FormData 建構函式

利用泛型與映射型別(Mapped Types),寫一個能把任意符合介面的物件,轉換成 FormData 的函式。

/**
 * 把符合 T 形狀的物件轉成 FormData。
 * - 只會加入值不為 undefined 的欄位
 * - 內部自動處理 File/Blob 與基本型別的轉換
 */
function toFormData<T extends Record<string, any>>(obj: T): FormData {
  const form = new FormData();

  (Object.keys(obj) as (keyof T)[]).forEach((key) => {
    const value = obj[key];
    if (value === undefined || value === null) return; // 跳過未設定

    // File/Blob 直接加入,其他型別則轉成字串
    if (value instanceof Blob) {
      form.append(key as string, value);
    } else if (Array.isArray(value)) {
      // 支援陣列:key[] 的寫法
      value.forEach((v) => form.append(`${key}[]` as string, v as any));
    } else {
      form.append(key as string, String(value));
    }
  });

  return form;
}

3. 使用型別安全的 FormData 進行上傳

async function uploadUserProfile(data: UserProfileForm) {
  const form = toFormData(data); // ✅ 編譯階段已確保欄位正確

  const response = await fetch('/api/user/profile', {
    method: 'POST',
    body: form,
    // 注意:不需要自行設定 Content-Type,瀏覽器會自動產生 boundary
  });

  if (!response.ok) {
    throw new Error('上傳失敗');
  }
  return response.json();
}

// 呼叫範例
uploadUserProfile({
  username: 'alice',
  email: 'alice@example.com',
  avatar: document.querySelector('#avatar')?.files?.[0],
  age: 28,
});

提示:若你使用 axios,只要把 form 當成 data 傳入即可,axios 會自動偵測 FormData

4. 以型別映射產生「必填」與「可選」欄位的輔助類型

有時候想要在編譯期保證 必填欄位一定被設定,可以透過條件型別產生「必填」的子集合。

type RequiredKeys<T> = {
  [K in keyof T]-?: undefined extends T[K] ? never : K
}[keyof T];

type OptionalKeys<T> = Exclude<keyof T, RequiredKeys<T>>;

// 例:取得 UserProfileForm 必填欄位的型別
type UserProfileRequired = Pick<UserProfileForm, RequiredKeys<UserProfileForm>>; // { username: string; email: string; }
type UserProfileOptional = Pick<UserProfileForm, OptionalKeys<UserProfileForm>>; // { avatar?: File; age?: number; }

這樣在函式簽名中,我們可以寫出:

function createFormData<R extends RequiredKeys<T>, T extends object>(obj: Pick<T, R> & Partial<T>) {
  // R 為必填鍵集合,編譯器會提示缺少欄位時出錯
  return toFormData(obj);
}

5. 讀取 FormData 時的型別安全

雖然大多數情況是「寫入」 FormData,偶爾也需要「讀取」回傳的資料(例如在單元測試或伺服器端)。以下示範如何用泛型取得正確型別:

function getFormDataValue<T extends Record<string, any>>(
  form: FormData,
  key: keyof T
): T[typeof key] | null {
  const value = form.get(key as string);
  if (value === null) return null;

  // 依照預期型別做簡單轉換
  if (typeof ({} as T)[key] === 'number') {
    return Number(value) as any;
  }
  if (value instanceof File) {
    return value as any;
  }
  return String(value) as any;
}

// 使用範例
const fd = toFormData<UserProfileForm>({
  username: 'bob',
  email: 'bob@example.com',
});
const email = getFormDataValue<UserProfileForm>(fd, 'email'); // email 型別為 string | null

常見陷阱與最佳實踐

陷阱 說明 解決方式
直接使用 any FormData 宣告為 any 會失去型別檢查的好處。 使用上面提供的 toFormData<T>() 泛型函式,讓編譯器自行推斷。
忘記設定必填欄位 只在程式執行時才會拋出錯誤,使用者體驗不佳。 利用 RequiredKeys<T> 產生必填鍵集合,或在介面中明確標記 ?:
Content-Type 手動設定 手動寫 multipart/form-data 會缺少 boundary,導致後端解析失敗。 不要 手動設定 Content-Type,讓瀏覽器自行處理。
陣列欄位的命名不一致 後端期待 field[] 但前端只傳 field,會導致資料遺失。 toFormData 中加入陣列支援,使用 ${key}[] 的寫法。
Blob 與 string 混用 把文字直接以 Blob 形式傳送會產生編碼問題。 僅在需要時(例如上傳檔案)使用 Blob,其他皆以字串傳遞。
忘記檢查 File 是否存在 input.files[0]undefined 時直接加入會拋錯。 在呼叫 toFormData 前檢查 file?.size > 0,或在函式內過濾 undefined

最佳實踐

  1. 定義表單介面,將所有欄位列出,必要時使用 readonly 防止意外改寫。
  2. 使用泛型工具函式 (toFormData<T>) 產生 FormData,確保所有欄位在編譯期即被驗證。
  3. 在 API 層加入型別:把 fetch/axios 包裝成 postForm<T>(url, data: T),讓每一次呼叫都有型別保證。
  4. 加入單元測試:利用 jestvitest 檢查 toFormData 是否正確轉換陣列、Blob、數字等。
  5. 保持一致的命名慣例:前端與後端協商好欄位名稱(例如 snake_case vs camelCase),避免因大小寫差異造成錯誤。

實際應用場景

1. 使用者註冊與大頭貼上傳

interface RegisterForm {
  username: string;
  password: string;
  email: string;
  avatar?: File;
}

// 前端表單提交
async function submitRegister(form: RegisterForm) {
  const fd = toFormData(form);
  const res = await fetch('/api/auth/register', { method: 'POST', body: fd });
  // ...
}
  • 型別安全:若忘記填 email,編譯器會直接報錯。
  • 自動完成:IDE 會提示 avatar 必須是 File,避免把 string 誤傳。

2. 多檔案上傳(如部落格文章附圖)

interface BlogPostForm {
  title: string;
  content: string;
  tags?: string[];          // 多值欄位
  images?: File[];          // 多檔案
}

function uploadBlogPost(data: BlogPostForm) {
  const fd = toFormData(data);
  return fetch('/api/blog/posts', { method: 'POST', body: fd });
}
  • toFormData 會自動把 tags 轉成 tags[]images 轉成 images[],後端只要處理陣列即可。

3. 表單驗證與錯誤回報

在表單送出前,我們可以先利用 TypeScript 的型別檢查,結合 YupZod 等驗證函式庫,確保所有欄位符合規則,再交給 toFormData

import { z } from 'zod';

const registerSchema = z.object({
  username: z.string().min(3),
  password: z.string().min(8),
  email: z.string().email(),
  avatar: z.instanceof(File).optional(),
});

type RegisterForm = z.infer<typeof registerSchema>;

async function handleRegister(input: unknown) {
  const parsed = registerSchema.parse(input); // 若驗證失敗會拋錯
  await submitRegister(parsed);
}

透過型別與驗證雙重保護,前端錯誤率大幅下降,後端也不必再重複檢查。


總結

  • FormData 本身缺乏型別資訊,容易在大型專案中產生隱藏錯誤。
  • 透過 介面 + 泛型函式 (toFormData<T>()) 可以在 編譯階段 捕捉欄位名稱、必填性與型別錯誤。
  • 使用 映射型別 (RequiredKeys<T>, OptionalKeys<T>) 能進一步保證必填欄位不會遺漏。
  • 最佳實踐 包含:明確定義表單介面、避免手動設定 Content-Type、處理陣列與 Blob、加入單元測試與驗證。
  • 實務場景(使用者註冊、部落格多檔案上傳、表單驗證)中,型別安全的 FormData 不僅提升開發效率,也降低上線後的錯誤率。

將上述技巧套用到日常開發流程,你會發現 TypeScript 的型別系統不只是寫程式的輔助工具,更是保護資料傳輸安全、提升團隊協作品質的關鍵。祝你在 TypeScript 與 FormData 的世界裡寫出更乾淨、更可靠的程式碼!