TypeScript 聯合與交集型別:靈活組合型別的實用技巧

2025年7月25日

💎 加入 E+ 成長計畫 與超過 700+ 位工程師一同在社群成長,並獲得更多深度的軟體前後端學習資源

在開發較為複雜的 TypeScript 應用程式時,相信大家都會遇到這樣的情況:單一型別已經無法滿足實際需求。假如我們要處理 API 回應,可能會成功也可能失敗;或者要建立彈性的元件 props,需要接受多種不同的組合;又或者資料結構會依照使用者的互動而動態變化。

這些真實世界的開發場景,都需要更加靈活的型別定義方式。

舉個實際例子,假設我們在開發一個使用者認證系統,登入嘗試可能會回傳不同的結果。成功時會帶回完整的使用者資料,包含權限和偏好設定;失敗時則只會回傳錯誤訊息,詳細說明出錯的原因。我們的應用程式需要妥善處理這兩種截然不同的情況。

TypeScript 提供了兩個互補的工具來解決這類挑戰:聯合型別處理「這個或那個」的情況,交集型別則用來處理「這個和那個」的需求。

這些功能讓我們能夠建模複雜的真實世界資料關係,同時保持 TypeScript 引以為傲的型別安全性。學完這篇文章後,讀者會了解如何有效組合型別,打造出強韌且易於維護的程式碼。

當單一型別不夠用時:認識聯合型別

聯合型別代表一個值可能是數種型別中的其中一種。我們使用管線符號(|)來建立聯合型別,讀起來就像「或」的意思。當你寫下 string | number 時,就是在告訴 TypeScript「這個值不是字串就是數字」。

以下是一個實際的範例,用來處理文章觀看次數的格式化:

function formatViewCount(views: string | number): string {
  // TypeScript 知道 views 可能是兩種型別中的任一種
  console.log(views); // 這行可以正常執行,因為兩種型別都支援 console.log

  // 但這行不會通過:
  return views.toUpperCase(); // 錯誤:Property 'toUpperCase' does not exist on type 'number'
}

關鍵在於,在函式內部,TypeScript 只允許你存取聯合型別中「所有」可能型別都有的屬性和方法。由於 toUpperCase() 只存在於字串而不存在於數字,TypeScript 會阻止你直接呼叫它。

這看起來可能有點限制,但其實是在保護我們避免執行時錯誤。如果沒有這層保護,對數字呼叫 toUpperCase() 會讓應用程式當掉。

聯合型別在建模結構真正有所不同的真實世界資料時特別有用。以一篇 ExplainThis 文章為例,可能處於草稿或已發布狀態:

type DraftArticle = {
  id: string;
  title: string;
  content: string;
  status: "draft";
};

type PublishedArticle = {
  id: string;
  title: string;
  content: string;
  status: "published";
  publishDate: Date;
  viewCount: number;
};

type Article = DraftArticle | PublishedArticle;

function getArticleInfo(article: Article) {
  // 這些屬性在兩種型別中都存在
  console.log(`標題:${article.title}`);
  console.log(`狀態:${article.status}`);

  // 但 publishDate 只存在於 PublishedArticle
  console.log(article.publishDate); // 錯誤!
}

這個範例展示了聯合型別如何讓我們建模具有一些共同屬性但又有差異的資料。兩種文章型別都有 idtitlecontentstatus,但只有已發布的文章才有 publishDateviewCount

讓聯合型別更實用:型別守衛

如果聯合型別只能存取共同屬性,那用途就太有限了。真正的威力來自 TypeScript 根據程式碼邏輯縮小型別的能力。這個過程稱為型別縮窄,透過型別守衛來實現。

最簡單的型別守衛使用 typeof 運算子:

function formatViewCount(views: string | number): string {
  if (typeof views === "string") {
    // TypeScript 現在知道 views 肯定是字串
    return views.toUpperCase(); // 這行可以正常執行!
  }

  // TypeScript 知道 views 在這裡一定是數字
  return views.toLocaleString(); // 這行也能正常執行!
}

TypeScript 的控制流程分析足夠聰明,能夠追蹤條件邏輯中的型別資訊。一旦你檢查了 typeof views === "string",TypeScript 就會在該區塊內將型別縮窄為 string。在 else 區塊中,它知道值一定是 number

對於物件聯合,可以使用屬性檢查:

function displayArticle(article: Article) {
  if (article.status === "published") {
    // TypeScript 縮窄為 PublishedArticle
    console.log(`發布日期:${article.publishDate}`);
    console.log(`觀看次數:${article.viewCount}`);
  } else {
    // TypeScript 縮窄為 DraftArticle
    console.log("這篇文章還在草稿狀態");
  }
}

status 屬性之所以能當作判別器,是因為它在每種型別中都有不同的字面值("draft""published")。TypeScript 利用這個資訊來判斷你正在處理的是哪種特定型別。

你也可以使用 in 運算子來檢查屬性是否存在:

function displayArticle(article: Article) {
  if ("publishDate" in article) {
    // TypeScript 知道這是 PublishedArticle
    console.log(`發布日期:${article.publishDate.toDateString()}`);
  }
}

這種方法有效是因為 publishDate 只存在於 PublishedArticle,所以它的存在就表示你在處理哪種型別。

真實世界的模式:判別聯合

最強大的模式之一是將聯合型別與一致的判別屬性結合。這些判別聯合(也稱為標籤聯合)讓型別縮窄變得可預測且完整。

以下展示如何在 ExplainThis 中建模不同類型的通知:

type CommentNotification = {
  type: "comment";
  articleId: string;
  commenterName: string;
  commentText: string;
};

type LikeNotification = {
  type: "like";
  articleId: string;
  likerName: string;
};

type FollowNotification = {
  type: "follow";
  followerName: string;
  followerBio: string;
};

type Notification = CommentNotification | LikeNotification | FollowNotification;

// 看看當你試圖存取錯誤屬性時會發生什麼:
function brokenFormatNotification(notification: Notification): string {
  // 這會導致 TypeScript 錯誤!
  return `${notification.commenterName} 做了某件事`;
  // 錯誤:Property 'commenterName' does not exist on type 'Notification'
  // Property 'commenterName' does not exist on type 'LikeNotification'
  // Property 'commenterName' does not exist on type 'FollowNotification'
}

為什麼會出錯呢?可以這樣想:當你收到一個 Notification 時,TypeScript 還不知道它具體是哪種型別。它可能是聯合中的任一種型別。由於 commenterName 只存在於 CommentNotification 而不存在於 LikeNotificationFollowNotification,TypeScript 會阻止你直接存取它。

這是 TypeScript 在保護你避免執行時錯誤。如果你試圖在按讚通知上存取 commenterName,程式會當掉,因為該屬性根本不存在。型別系統強制你先使用 type 屬性檢查是哪種型別,如下所示:

function formatNotification(notification: Notification): string {
  switch (notification.type) {
    case "comment":
      // TypeScript 知道這是 CommentNotification
      return `${notification.commenterName} 留言:「${notification.commentText}`;

    case "like":
      // TypeScript 知道這是 LikeNotification
      return `${notification.likerName} 對你的文章按讚`;

    case "follow":
      // TypeScript 知道這是 FollowNotification
      return `${notification.followerName} 開始追蹤你`;

    default:
      // TypeScript 確保這是完整的
      const exhaustive: never = notification;
      throw new Error(`未處理的通知類型:${exhaustive}`);
  }
}

type 屬性作為判別器,讓 TypeScript 能夠確定你正在處理的具體通知型別。default case 中的 never 型別確保如果你新增了通知型別但忘記在 switch 陳述式中處理它,TypeScript 會顯示錯誤。

這個模式對狀態管理、API 回應,以及任何有限組合相關但結構不同的資料場景都非常有用。

組合需求:交集型別

聯合型別處理「或」的場景,而交集型別解決「與」的問題。交集型別將多個型別合併為一個,具有每個組成型別的所有屬性。使用 & 符號來建立交集型別。

假設我們要為 ExplainThis 建立一個使用者管理系統,需要組合不同的能力集合:

type User = {
  id: string;
  username: string;
  email: string;
};

type Author = {
  articlesWritten: number;
  bio: string;
};

type Moderator = {
  canDeleteComments: boolean;
  canBanUsers: boolean;
};

// 既是作者又是版主的使用者
type AuthorModerator = User & Author & Moderator;

function setupAuthorModerator(user: AuthorModerator) {
  // 來自三種型別的所有屬性都可以使用
  console.log(`使用者:${user.username}`); // 來自 User
  console.log(`文章數:${user.articlesWritten}`); // 來自 Author
  console.log(`可以封禁:${user.canBanUsers}`); // 來自 Moderator
}

產生的 AuthorModerator 型別具有 UserAuthorModerator 的每個屬性。這讓你能像組裝積木一樣組合型別,避免複雜的繼承階層。

交集型別特別適用於以額外屬性擴展現有型別:

type BaseArticle = {
  id: string;
  title: string;
  content: string;
};

type ArticleWithMetrics = BaseArticle & {
  viewCount: number;
  likeCount: number;
  shareCount: number;
};

type ArticleWithComments = BaseArticle & {
  comments: Comment[];
  commentCount: number;
};

// 你甚至可以交集交集型別
type FullArticle = ArticleWithMetrics & ArticleWithComments;

讓我們逐步分析這裡發生的事情:

  1. BaseArticle 定義每篇文章需要的核心屬性:ID、標題和內容
  2. ArticleWithMetrics 取用 BaseArticle 並使用 & 新增互動指標。結果具有所有 BaseArticle 屬性加上三個新的指標屬性
  3. ArticleWithComments 也從 BaseArticle 開始,但改為新增留言相關的屬性
  4. FullArticle 結合兩個增強版本,給你一個同時具有指標和留言的文章型別

可以把交集型別想像成為基礎模型新增功能。如果 BaseArticle 是基本款汽車,那麼 ArticleWithMetrics 就是同款車加上高級音響系統,而 ArticleWithComments 是加上真皮座椅的版本。FullArticle 則是同時具有高級音響系統和真皮座椅的汽車。

以下是 FullArticle 物件的樣子:

const fullArticle: FullArticle = {
  // 來自 BaseArticle
  id: "article-123",
  title: "深入理解 TypeScript",
  content: "TypeScript 是一個強大的...",

  // 來自 ArticleWithMetrics
  viewCount: 1250,
  likeCount: 89,
  shareCount: 23,

  // 來自 ArticleWithComments
  comments: [
    /* 留言陣列 */
  ],
  commentCount: 15,
};

這種組合方式比繼承更靈活,因為你可以根據需要混合搭配能力,而 TypeScript 確保你滿足所有要求。

整合運用

聯合型別和交集型別是在 TypeScript 中表達複雜型別關係的基礎工具。聯合型別(|)處理值可能是數種型別之一的「或」場景,而交集型別(&)將多個型別合併為具有所有屬性的一個型別。

型別守衛讓聯合型別變得實用,讓你根據條件邏輯縮窄型別。使用 typeof、屬性檢查或 in 運算子來幫助 TypeScript 理解你正在處理哪種具體型別。具有一致判別屬性的判別聯合創造了可預測、完整的型別檢查模式。

交集型別擅長組合,讓你從較簡單的組件建構複雜型別,而不需要嚴格的繼承階層。這種方法創造出靈活、易維護的程式碼,準確建模你的領域,同時在編譯時期捕捉不一致的地方。

這些功能結合起來,將 TypeScript 從簡單的型別檢查器轉變為強大的建模語言,能夠適應真實世界的複雜性,同時保持安全性和清晰度。


加入 E+ 成長計畫

如果你覺得這篇內容有幫助,喜歡我們的內容,歡迎加入 ExplainThis 籌辦的 E+ 成長計畫,透過每週的深度主題文,以及技術討論社群,讓讀者在前端、後端、軟體工程的領域上持續成長。

🧵 如果你想收到最即時的內容更新,可以在 FacebookInstagram 上追蹤我們