EP.14網頁開發實戰

TypeScript 入門:型別是你的隊友
從 JS 工程師的角度理解型別思維

TypeScript 不是讓你多寫程式碼的工具——它是讓你少 debug 的工具。 這篇從「為什麼要加型別」出發,帶你掌握 Interface、泛型、嚴格模式等核心概念。

Joseph Chen 2026 15 min read TypeScript · Interface · Generics · Type System

「TypeScript 在你執行程式之前就告訴你哪裡錯了—— 而不是讓你在生產環境的 3 點鐘接到警報才發現。」

本系列所有的 Next.js 程式碼都是用 TypeScript 寫的,但我們一直沒有正面介紹它。 這篇把欠的帳還清——從型別系統的設計哲學,到你在 React 開發中最常用到的 TS 特性。

為什麼要用 TypeScript?

沒有型別的世界會發生什麼
// JavaScript(無型別)
function getUser(id) {
  return fetch('/api/users/' + id).then(r => r.json());
}

const user = await getUser(1);
console.log(user.nmae);  // typo!但 JS 不報錯,執行時才發現是 undefined

// ─────────────────────────────────────────────
// TypeScript(有型別)
type User = {
  id: number;
  name: string;
  email: string;
};

async function getUser(id: number): Promise<User> {
  const res = await fetch('/api/users/' + id);
  return res.json();
}

const user = await getUser(1);
console.log(user.nmae);  // ❌ 編譯錯誤:Property 'nmae' does not exist on type 'User'
console.log(user.name);  // ✅ IDE 自動補全,100% 正確

🔍

編譯期錯誤發現

打字錯誤、錯誤的 API 呼叫、undefined 存取,在執行前就被抓到。

💡

IDE 自動補全

知道型別,IDE 就能提示可用的屬性和方法,大幅提升開發速度。

📖

程式碼即文件

函數的參數型別和回傳型別本身就是最準確的文件,不需要另外維護。

⚠️ TypeScript 只保護你到「編譯期」

TypeScript 在 tsc 編譯時做型別檢查,編譯後輸出的是純 JavaScript。執行期的資料(API 回應、使用者輸入、localStorage)不受型別保護——你的型別定義只是「你跟編譯器說的話」,不是執行時的驗證。 這就是為什麼處理外部資料時,還需要 Zod 這類 runtime validation 工具。

基礎型別與型別推斷

TypeScript 基礎型別
// ── 基本型別 ──────────────────────────────────────
let name: string = 'Joseph';
let age: number = 26;
let isActive: boolean = true;
let data: null = null;
let value: undefined = undefined;

// ── 型別推斷:TypeScript 很聰明,通常不需要手動標注 ──
let count = 0;           // 推斷為 number
let title = 'Hello';     // 推斷為 string
// count = 'abc';        // ❌ 錯誤:不能把 string 指定給 number

// ── 陣列 ────────────────────────────────────────
const ids: number[] = [1, 2, 3];
const names: Array<string> = ['Alice', 'Bob'];

// ── 物件(inline 型別)──────────────────────────
const user: { id: number; name: string } = { id: 1, name: 'Joseph' };

// ── Union 型別(可以是多種型別之一)──────────────
let status: 'pending' | 'shipped' | 'delivered' = 'pending';
let id: string | number = 1;
id = 'abc';   // 也合法

// ── Literal 型別(精確的值)─────────────────────
type Direction = 'up' | 'down' | 'left' | 'right';
function move(dir: Direction) { /* ... */ }
move('up');      // ✅
// move('diagonal'); // ❌ 型別錯誤

// ── any 和 unknown(謹慎使用)──────────────────
let anything: any = 'hello';  // 關閉型別檢查,盡量避免
let safe: unknown = 42;       // 比 any 安全,使用前必須 type guard

unknown + Type Narrowing:比 any 更安全的做法

收到 unknown 型別後,必須先用 Type Guard 縮小型別範圍(Narrowing),才能存取屬性。 這讓你在處理外部資料時保持型別安全。

typescript
function processInput(value: unknown) {
  // ── typeof narrowing ──────────────────────────────
  if (typeof value === 'string') {
    console.log(value.toUpperCase());  // TS 知道這裡是 string
  }

  // ── instanceof narrowing ──────────────────────────
  if (value instanceof Date) {
    console.log(value.toISOString());  // TS 知道這裡是 Date
  }

  // ── 自定義 Type Guard(is 關鍵字)────────────────
  function isUser(obj: unknown): obj is User {
    return (
      typeof obj === 'object' && obj !== null &&
      'id' in obj && typeof (obj as any).id === 'number'
    );
  }

  if (isUser(value)) {
    console.log(value.id);   // TS 知道這裡是 User
  }
}

Interface vs Type:怎麼選?

這是 TypeScript 最常見的問題。兩者功能很相近,但有幾個關鍵差異。

Interface 與 Type 的差異
// ── Interface:定義物件的形狀 ──────────────────────
interface User {
  id: number;
  name: string;
  email?: string;  // ? 代表可選屬性
  readonly createdAt: Date;  // readonly:只讀不可改
}

// Interface 可以繼承(extends)
interface Admin extends User {
  role: 'admin' | 'superadmin';
  permissions: string[];
}

// Interface 可以宣告合併(Declaration Merging)
// 同名 interface 會自動合併——擴充第三方 library 時很有用,但也是陷阱來源
interface User { phone?: string; }  // 合法!現在 User 多了 phone 欄位
// ⚠️ 陷阱:若你定義了和第三方庫同名的 interface(例如 Session),
// 你的合併會悄悄改變第三方型別的形狀,導致奇怪的型別錯誤:
// interface Session { userId: number; }  // 如果第三方庫已有 Session,這會合併!
// → 避免在全域 scope 定義與第三方庫同名的 interface

// ── Type Alias:更靈活的型別定義 ─────────────────
type Point = { x: number; y: number };

// Type 可以定義 Union、Intersection 等複雜型別(Interface 不行)
type ID = string | number;
type Response<T> = { data: T; error: string | null };

// Type 用 & 做交叉型別(類似繼承)
type AdminUser = User & { role: string };

// ── 實務建議 ─────────────────────────────────────
// ✅ 物件形狀(API 回應、Props)→ 用 Interface
// ✅ Union / Intersection / 別名 → 用 Type
// ✅ React 組件 Props → Interface(社群慣例)
// 兩者差異其實不大,保持一致風格比選哪個更重要

在 React + Next.js 中的慣例

組件的 Props 通常用 interface(因為語義上代表「這個組件接受什麼形狀的輸入」), API 回應、Redux state、資料模型通常用 type。 最重要的是整個專案保持一致——不要一半用 Interface 一半用 Type。

泛型(Generics):可重複使用的型別

泛型讓你寫出「型別安全的通用程式碼」——邏輯相同但適用於不同型別的情況。 它是 TypeScript 最強大的特性,也是最多人跳過的部分。

泛型的基本用法
// ── 問題:沒有泛型,要為每種型別寫重複的函數 ────────
function firstItem_number(arr: number[]): number { return arr[0]; }
function firstItem_string(arr: string[]): string { return arr[0]; }
// 這樣很冗餘...

// ── 解法:泛型讓函數「型別參數化」─────────────────
function firstItem<T>(arr: T[]): T {
    return arr[0];
}
firstItem([1, 2, 3]);       // 推斷 T = number,回傳 number
firstItem(['a', 'b', 'c']); // 推斷 T = string,回傳 string

// ── 實際案例:API 回應的通用型別 ──────────────────
interface ApiResponse<T> {
    data: T;
    message: string;
    success: boolean;
}

// 使用時代入具體型別
type UserResponse = ApiResponse<User>;     // data 是 User
type ListResponse = ApiResponse<User[]>;   // data 是 User 陣列

async function fetchUser(id: number): Promise<ApiResponse<User>> {
    const res = await fetch('/api/users/' + id);
    return res.json();
}

// ── React Hooks 中的泛型 ──────────────────────────
const [users, setUsers] = useState<User[]>([]);   // 明確告訴 TS 是 User 陣列
const [error, setError] = useState<string | null>(null);

// ── 泛型約束(extends)────────────────────────────
// 限制 T 必須有 id 屬性
function findById<T extends { id: number }>(list: T[], id: number): T | undefined {
    return list.find(item => item.id === id);
}
findById(users, 1);    // ✅ User 有 id
// findById([1,2,3], 1); // ❌ number 沒有 id 屬性

TypeScript 在 React 中的實戰

學完泛型後,React 的 TypeScript 寫法其實很直覺:useState<User | null>(null)useRef<HTMLInputElement>(null)—— 這些都是泛型的直接應用,TypeScript 幫你確保 state 的型別和元件 Props 的形狀, 編譯期就能抓到 API 回傳值型別錯誤或 Props 傳錯型別的問題。

React 組件的 TypeScript 寫法
// ── 1. 組件 Props 型別 ────────────────────────────
interface ButtonProps {
    label: string;
    onClick: () => void;
    variant?: 'primary' | 'secondary' | 'danger';
    disabled?: boolean;
    children?: React.ReactNode;  // 可以接受任何 JSX 內容
}

function Button({ label, onClick, variant = 'primary', disabled }: ButtonProps) {
    return (
        <button
            onClick={onClick}
            disabled={disabled}
            className={variant === 'primary' ? 'bg-blue-500' : 'bg-gray-500'}
        >
            {label}
        </button>
    );
}

// ── 2. useState 與 useRef ──────────────────────────
const [user, setUser] = useState<User | null>(null);
const inputRef = useRef<HTMLInputElement>(null);  // DOM 元素型別

// ── 3. Event 型別 ─────────────────────────────────
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    console.log(e.target.value);
}

function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    // ...
}

// ── 4. 子組件傳遞 children ────────────────────────
interface CardProps {
    title: string;
    children: React.ReactNode;  // 最彈性的子組件型別
}

// ── 5. 非同步函數的回傳型別 ──────────────────────
async function loadData(): Promise<User[]> {
    const res = await fetch('/api/users');
    if (!res.ok) throw new Error('Failed to fetch');
    return res.json() as Promise<User[]>;  // as 做型別斷言
}

tsconfig.json 嚴格模式:你應該開啟的選項

tsconfig.json 推薦設定
{
  "compilerOptions": {
    "strict": true,           // 開啟所有嚴格模式(強烈建議)
    // strict: true 等同於以下設定:
    // "noImplicitAny": true,         // 不允許隱式 any
    // "strictNullChecks": true,      // null 和 undefined 必須明確處理
    // "strictFunctionTypes": true,   // 函數型別嚴格檢查
    // "strictPropertyInitialization": true, // 類別屬性必須初始化

    "noUnusedLocals": true,    // 未使用的本地變數報錯
    "noUnusedParameters": true, // 未使用的函數參數報錯
    "noImplicitReturns": true,  // 函數所有路徑都必須有 return

    "target": "ES2020",
    "lib": ["ES2020", "DOM"],
    "moduleResolution": "bundler",
    "jsx": "preserve"
  }
}

最重要的:strictNullChecks

typescript
// strictNullChecks 開啟後
function getLength(str: string | null): number {
    // return str.length;  // ❌ 錯誤:str 可能是 null

    if (str === null) return 0;
    return str.length;     // ✅ TS 知道這裡 str 一定是 string(Narrowing)

    // 或用可選鏈
    return str?.length ?? 0;
}

常見陷阱與最佳實踐

⚠️ 陷阱:過度使用 any

❌ 常見寫法

const data: any = fetchSomething();  // 型別檢查被關閉

✅ 更好的做法

const data: unknown = fetchSomething();
if (typeof data === 'object' && data !== null) { /* safe */ }

💡 any 讓 TypeScript 沉默,但不能讓錯誤消失。用 unknown 強制自己做型別檢查。

⚠️ 陷阱:型別斷言(as)的濫用

❌ 常見寫法

const user = data as User;  // 強制告訴 TS「相信我」,但可能錯

✅ 更好的做法

// 用型別守衛(Type Guard)更安全
function isUser(obj: unknown): obj is User {
  return typeof obj === 'object' && obj !== null && 'id' in obj;
}

💡 型別斷言是「逃生艙」,不是正常手段。寧可用型別守衛。

⚠️ 陷阱:忽略 API 回應的型別

❌ 常見寫法

const res = await fetch('/api/user');
const user = await res.json();  // user 是 any!

✅ 更好的做法

const res = await fetch('/api/user');
const user = await res.json() as User;  // 至少斷言
// 更好:用 zod 或 superstruct 做 runtime 驗證

💡 fetch 的 .json() 永遠回傳 any。考慮用 Zod 做 schema validation。

進階:用 Zod 做 Runtime 型別驗證

TypeScript 型別在執行期消失,API 回應可能不符合你定義的型別。Zod 讓你同時得到「型別推斷」和「執行期驗證」:

typescript
import { z } from 'zod';

// 定義 schema(同時也是型別來源)
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;  // 從 schema 推斷 TypeScript 型別

// 執行期驗證 API 回應
const res = await fetch('/api/user');
const data = await res.json();
const user = UserSchema.parse(data);  // 若格式不符,丟出明確的錯誤訊息
// user 的型別是 User(TS 自動推斷)

本篇重點回顧

🛡️TypeScript 在編譯期發現錯誤,提供 IDE 自動補全,讓程式碼本身成為文件。
🔤基礎型別包含 string / number / boolean / null / undefined,以及 Union(|)和 Literal 型別。善用型別推斷,不用每行都手動標注。
📐Interface 適合定義物件形狀,Type 適合 Union / Intersection 等複雜型別。保持專案風格一致比選哪個更重要。
🔧泛型讓程式碼可重複使用:function<T>、interface Response<T>——在 React Hooks 和 API 呼叫中大量用到。
⚙️開啟 strict: true(特別是 strictNullChecks),早期捕捉 null / undefined 錯誤。
🚫避免 any,用 unknown 代替;型別斷言(as)只用在必要時;API 回應型別要明確定義。
TypeScript
Type System
Interface
Generics
React
strict mode
EP.14