TypeScript 實務開發與架構應用 – FormData 型別安全處理
簡介
在前端開發中,FormData 是與後端傳遞檔案、表單資料的常見工具。它可以直接把 <input type="file">、文字欄位等包裝成 multipart/form-data,讓 fetch 或 axios 等 HTTP 客戶端無縫上傳。然而,原生的 FormData 完全是「字串」與「Blob」的集合,缺乏型別資訊,導致在 TypeScript 專案中很容易寫出錯誤卻不被編譯器捕捉。
本文將說明如何在 TypeScript 中為 FormData 加上型別安全的包裝,讓開發者在編寫上傳程式碼時,能夠:
- 在編譯階段就捕捉欄位名稱拼寫錯誤
- 保證必填欄位一定被設定
- 提供自動完成與文件提示,提升開發效率
透過實作範例、常見陷阱與最佳實踐,帶你把「隨意」的 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。 |
最佳實踐:
- 定義表單介面,將所有欄位列出,必要時使用
readonly防止意外改寫。 - 使用泛型工具函式 (
toFormData<T>) 產生FormData,確保所有欄位在編譯期即被驗證。 - 在 API 層加入型別:把
fetch/axios包裝成postForm<T>(url, data: T),讓每一次呼叫都有型別保證。 - 加入單元測試:利用
jest或vitest檢查toFormData是否正確轉換陣列、Blob、數字等。 - 保持一致的命名慣例:前端與後端協商好欄位名稱(例如
snake_casevscamelCase),避免因大小寫差異造成錯誤。
實際應用場景
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 的型別檢查,結合 Yup、Zod 等驗證函式庫,確保所有欄位符合規則,再交給 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 的世界裡寫出更乾淨、更可靠的程式碼!