TypeScript 的變數與函式:如何進行型別安全的宣告與使用

2025年7月25日

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

假如你是過去曾開發純 JavaScript 的工程師,相信或多或少有過以下的經驗,你在開發新功能時一切都很順利,豈知上線後卻有使用者回報說系統壞了。而試著 debug 後才發現,原來在程式碼的某個地方,有個函式預期要接收一個數字,卻收到了字串;又或者,某個同事在應該傳物件的地方,傳了 null,結果導致整個功能直接炸掉。

上面這種問題的之所以會發生,是因為 JavaScript 這個語言本身沒有型別檢查;換句話說,雖然 JavaScript 有型別,但是即使開發者用錯型別,在寫 JavaScript 的時候,也不會看到相關的報錯。因此,直到出事之前,一切看起來都沒問題,殊不知程式碼中有潛在的未爆彈。

上一篇文章中,我們探討了 TypeScript 的 stringnumberboolean 等基礎型別。在文章中,我們用範例說明這些型別如何在程式碼執行前,就幫我們抓出潛在的 bug。

在了解完這些基礎後,相信讀者們可能會問「寫 TypeScript 函式跟 JavaScript 函式,有什麼不同?」。畢竟在絕多數的 JavaScript 開發工作中,寫函式是大家最常要做的事,因此在這篇文章我們會來談在 TypeScript 中,如何為變數與函式加上型別。

當你看到 TypeScript 如何處理我們日常撰寫的程式碼時,答案就會變得很明顯。語法看起來很熟悉,但它所提供的「安全保證」卻是天壤之別。讓我們以 ExplainThis 的文章推薦系統為例,思考一下會發生什麼事:

function getRecommendations(userId, articleCount) {
  const user = getUserById(userId);
  const preferences = user.preferences || {};

  return findArticles(preferences.categories, articleCount);
}

// JavaScript 會默默接受所有這些呼叫,不會有任何警告
getRecommendations("user_123", 5); // 正常運作
getRecommendations("user_123", "five"); // 傳入錯誤型別,之後才會出錯
getRecommendations(null, 5); // 直接崩潰,報錯 "Cannot read property 'preferences' of null"

對於以上這些函式呼叫,JavaScript 全都不會提出任何異議。錯誤只會在程式碼實際執行,並試圖使用錯誤資料時才會浮現。TypeScript 透過在程式碼執行前就檢查你的變數宣告與函式呼叫,徹底改變了這個狀況。

為什麼變數宣告在 TypeScript 中更為重要?

在寫 JavaScript 時,你可能會像下面這樣宣告變數,而且通常不太會多想:

let currentUser = getCurrentUser();
let articleCount = 0;
let isPublished = false;

這段程式碼通常能正常運作,直到 getCurrentUser() 回傳了 null,或是直到其他部分的程式碼不小心把一個字串賦值給 articleCount。TypeScript 透過讓這些潛在問題在第一時間就現形,來幫助我們避免災難。

這裡有一個徹底改變我思維的心智模型:把 TypeScript 的變數宣告,想像成是跟未來的自己簽訂一份合約。當你在 TypeScript 中宣告一個變數時,你不只是在建立一個裝資料的容器——你更是在做出一個「承諾」,保證這個變數裡只會存放特定種類的資料,而 TypeScript 則成為了你的「履約監督夥伴」。

試想一下,當你寫下 let articleCount = 0 時,你的腦中在想什麼。你心裡想的是:「這個變數會用來存放文章數量的數字。」但六個月後,當你在凌晨兩點除錯時,你可能會不小心寫出 articleCount = "loading..." 這樣的程式碼來顯示載入狀態。JavaScript 對此聳聳肩,讓你這麼做。但 TypeScript 會馬上跳出來阻止你,彷彿在說:「嘿,還記得你當初做的承諾嗎?」

當你在 TypeScript 中宣告變數時,你其實是在對它們將會持有的資料型別做出承諾。有時候 TypeScript 可以自己推斷出型別(這稱為型別推斷 (type inference),就像有個非常聰明的助理能理解你的意圖):

let articleTitle = "搞懂 TypeScript 的變數"; // TypeScript 知道這是 string
let viewCount = 1250; // TypeScript 知道這是 number
let isDraft = true; // TypeScript 知道這是 boolean

你有沒有注意到這裡的美妙之處:TypeScript 看了你的初始值後,就好像在說:「啊哈,我懂你的意思了。」這就像有個會注意上下文線索的結對程式設計夥伴。

如果你之後試圖打破這些承諾,TypeScript 就會馬上抓到你:

articleTitle = 42; // 錯誤:型別 'number' 不能指派給型別 'string'
viewCount = "一千"; // 錯誤:型別 'string' 不能指派給型別 'number'

這些錯誤起初可能看起來有點煩人,但它們其實是 TypeScript 在說:「我不太確定你這裡想做什麼,可以幫我理解一下嗎?」這是對 JavaScript 最常見 bug 來源——意外混用資料型別——的一種保護。

但如果 TypeScript 無法從上下文中推斷出你的意圖時,該怎麼辦?這時候,你就需要明確地告知它,而這也正是 TypeScript 強大之處的展現:

let authorRole: string; // 這個值會根據使用者權限來決定
let maxArticles: number | null = null; // 可能是一個數字限制,或是沒有限制
let blogConfig: { siteName: string; postsPerPage: number }; // 一個複雜的結構

// 之後在程式碼中賦值時:
authorRole = "editor"; // TypeScript 知道這是安全的
maxArticles = 50; // 這也是安全的
blogConfig = { siteName: "ExplainThis", postsPerPage: 10 }; // TypeScript 會驗證這個物件的結構

這裡真正發生的是:你正在教 TypeScript 你的資料「長什麼樣子」。那個 blogConfig 的宣告,就像是給 TypeScript 看一張藍圖,然後說:「任何被指派給這個變數的物件,都必須要擁有這些確切的屬性,以及這些確切的型別。」這不僅僅是型別檢查——這是結構性的驗證。

其中 number | null 這種語法(稱為聯集型別 (union type))特別強大。你等於在告訴 TypeScript:「這個變數要嘛會存放一個數字,要嘛會是 null,但絕對不會是其他東西。」這可以防止你不小心賦值 undefined 或一個字串,因為那樣會讓預期能用 maxArticles 做數學運算或檢查它是否為 null 的程式碼崩潰。

何時讓 TypeScript 推斷,何時該明確指定?

一個常讓許多開發者困惑的問題是:「我什麼時候該讓 TypeScript 自己推斷型別,什麼時候又該明確地寫出來?」這裡有一條對我非常有用的實用規則:

當意圖能從程式碼中明顯看出時,就讓 TypeScript 推斷。當意圖不明顯,或當你想要額外的安全保障時,就明確指定。

// 讓 TypeScript 推斷 - 意圖非常清楚
const siteName = "ExplainThis";
const maxRetries = 3;
const isEnabled = true;

// 明確指定 - 從初始化看不出完整意圖
let currentTheme: "light" | "dark" | "auto";
let apiResponse: User | null = null;
let processingStatus: "idle" | "loading" | "success" | "error" = "idle";

// 明確指定 - 你想為複雜資料加上額外的安全保障
const userPreferences: {
  theme: string;
  notifications: boolean;
  language: string;
} = getUserPreferences();

這裡的關鍵洞見在於,型別註記 (type annotations) 其實是一種溝通工具——它們向未來的程式碼讀者(包括你自己)溝通你的意圖,也向 TypeScript 溝通你的假設,好讓它能監督你履行承諾。

當你在處理來自程式碼外部的資料時,這種明確的型別定義就變得格外重要。這也是許多開發者第一次體驗到 TypeScript「啊哈!」時刻的地方:

// 沒有明確指定型別 - TypeScript 完全幫不上忙
let apiResponse = await fetch("/api/user").then((r) => r.json()); // TypeScript 只能把這當成 'any'
console.log(apiResponse.name.toUpperCase()); // 這裡不會顯示錯誤,但如果 name 不存在,執行時就會崩潰

// 有了明確指定型別 - TypeScript 能在你預期的基礎上提供幫助
let apiResponse: { name: string; email: string } | null = await fetch(
  "/api/user"
).then((r) => r.json());
console.log(apiResponse.name.toUpperCase()); // 錯誤:物件可能是 'null' - TypeScript 提醒你要先檢查!

這個洞見讓我豁然開朗:TypeScript 無法神奇地知道你的 API 會回傳什麼,但它可以監督你,確保你有處理好你「聲稱」它會回傳的情況。 當你寫下那個明確的型別註記時,你就在建立一份合約。你在說:「我預期這支 API 會回傳一個帶有 name 和 email 屬性的物件,或是 null。」TypeScript 接著就會說:「好的,如果你預期是這樣,那你就要處理 null 的情況。」

這在處理外部資料來源時尤其重要。你的資料庫可能回傳 null,你的 API 可能掛了,你的使用者可能還沒填寫他們的個人資料。透過在型別中明確地表達這些可能性,你等於是強迫自己去寫出更具防禦性的程式碼。

當然,如果 API 回傳的資料跟你預期的完全不同,那兩種寫法在執行時都還是會出錯。但有了明確的型別,TypeScript 至少能幫助你處理那些你「有預期到」的狀況,例如檢查 null 值或缺少的屬性。你等於是用「它可能會以我預想過的方式出錯」來換取「它可能會以各種奇怪的方式崩潰」。

處理可能不存在的資料

在真實世界的應用程式中,我們經常需要處理可能不存在的資料。使用者可能還沒設定他們的偏好,API 呼叫可能回傳 null,或者某個設定值是可選的。JavaScript 允許你在 null 或 undefined 的值上存取屬性,這就導致了那些令人頭痛的 "Cannot read property of null" 錯誤。

這裡有一個能幫助你轉換思維的觀點:在 JavaScript 中,null 和 undefined 是隱形的,直到它們引爆問題。在 TypeScript 中,它們是可見且大聲的。 TypeScript 強迫你去正視資料可能不存在的情況,這起初感覺很煩人,直到你意識到它正在預防正式環境的系統崩潰。

秘密在於,TypeScript 不只是在檢查 null 值——它更是在訓練你去思考程式碼所有可能失敗的方式。當你在一個型別中看到 | null| undefined 時,那就像是 TypeScript 在對你說:「嘿,這個東西可能不存在喔,你的備案是什麼?」

let userPreferences: { theme: string; notifications: boolean } | null = null;
let lastPublishedDate: Date | undefined = undefined;

// TypeScript 會阻止不安全的存取
console.log(userPreferences.theme); // 錯誤:物件可能是 'null'
console.log(lastPublishedDate.getFullYear()); // 錯誤:物件可能是 'undefined'

// 你必須先做檢查
if (userPreferences) {
  console.log(userPreferences.theme); // 安全:TypeScript 知道在這裡它不是 null
}

if (lastPublishedDate) {
  console.log(lastPublishedDate.getFullYear()); // 安全:TypeScript 知道在這裡它不是 undefined
}

這起初可能感覺像是在做額外的工作,但你寫下的每一個 null 檢查,都代表著你預防了一次潛在的系統崩潰。你是在用多寫幾行程式碼,來換取一個可靠性顯著提升的軟體。

最棒的是,一旦你開始用這種方式思考,你就會開始在應用程式的各個角落看到可能為空值的資料。那個使用者可能還沒上傳的大頭貼?那個可選的設定項目?那個可能為空陣列的 API 回應?TypeScript 幫助你用一致的方式處理所有這些情況,你的使用者就再也不會看到那些神秘的 "Cannot read property of null" 錯誤了。

真正強大的是,這徹底改變了你的除錯流程。你不再需要翻遍 log 來找出為什麼某個東西變成了 null,而是從一開始就防止了那些 null 狀態引發問題。

函式,終於能告訴你它們到底想要什麼

函式是 TypeScript 真正大放異彩的地方,也是你最有可能會驚嘆「我以前到底是怎麼活過來的?」的時刻。在 JavaScript 中,函式基本上就是個黑盒子。你把資料傳進去,然後祈禱那是正確種類的資料。TypeScript 則把函式變成了「合約」,明確規定了它們預期接收什麼,以及它們會回傳什麼。

你可以這樣想:沒有型別的函式,就像一台沒有任何標示的販賣機。 你投了錢進去,有東西掉出來,但你永遠不確定你會拿到什麼,也不知道你當初到底該投多少錢。有型別的函式,則像一台有清楚標示、圖片和價格的販賣機。你對能得到的東西有明確的預期。

思考一下這個用來計算閱讀時間的 JavaScript 函式:

function calculateReadingTime(wordCount, wordsPerMinute) {
  return Math.ceil(wordCount / wordsPerMinute);
}

如果你傳入數字,這個函式運作得很好,但 JavaScript 並不會阻止你這麼做:

calculateReadingTime("500", 200); // 回傳 NaN,因為 "500" / 200 的運算結果不如預期
calculateReadingTime(500); // 回傳 NaN,因為 wordsPerMinute 是 undefined

這兩種呼叫都會默默地產生 NaN,然後這個 NaN 會像病毒一樣在你的應用程式中傳播,把數字變成「不是個數字」,直到某個地方試圖向使用者顯示「需要 NaN 分鐘閱讀」。這個 bug 很隱晦,但症狀卻很明顯,而除錯的過程將會非常痛苦。

TypeScript 透過要求你指定函式預期的資料型別,來預防這些問題:

function calculateReadingTime(
  wordCount: number,
  wordsPerMinute: number
): number {
  return Math.ceil(wordCount / wordsPerMinute);
}

calculateReadingTime("500", 200); // 錯誤:型別 'string' 不能指派給型別 'number'
calculateReadingTime(500); // 錯誤:預期應有 2 個引數,但只得到 1 個

現在,這個函式變得「自我說明」了。任何看到這段程式碼的人,都能立刻知道它預期接收兩個數字,並回傳一個數字。但這裡有更深層的洞見:這些型別不只是文件,它們是「可執行的文件」。 一般的註解可能會說謊或過時,但如果你把這個函式改成回傳一個字串,TypeScript 會立刻告訴你,在你的程式碼庫中,所有預期它回傳數字的地方全都出錯了。

這些型別扮演了「永遠不會與實作脫節」的文件角色,因為它們本身就是實作的一部分。

你也可以明確指定函式回傳什麼,雖然 TypeScript 通常也能自己推斷出來:

function formatArticleTitle(title: string): string {
  return title.trim().toLowerCase().replace(/\s+/g, "-");
}

function getArticleWordCount(content: string) {
  return content.split(/\s+/).length; // TypeScript 會推斷這裡回傳 number
}

這裡有一個改變我除錯人生的實踐方法:對於任何超過幾行長的函式,都明確指定其回傳型別。 當 TypeScript 推斷回傳型別時,它的推斷是基於你「目前」的實作。但萬一你的實作是錯的呢?

當你在處理具有複雜邏輯或多個回傳路徑的函式時,明確指定回傳型別特別有幫助。它就像一張安全網,能捕捉到實作上的錯誤:

function getArticleStatus(article: {
  isDraft: boolean;
  publishDate: Date | null;
}): "draft" | "scheduled" | "published" {
  if (article.isDraft) {
    return "draft";
  }

  if (article.publishDate && article.publishDate > new Date()) {
    return "scheduled";
  }

  return "live"; // 錯誤:型別 '"live"' 不能指派給型別 '"draft" | "scheduled" | "published"'
}

TypeScript 抓到了我們回傳了 "live" 而不是 "published",這有助於確保我們的函式行為與系統其他部分保持一致。這種 bug 在程式碼審查中幾乎不可能被發現——它看起來很合理,邏輯似乎也沒錯,但它卻打破了這個函式所承諾要遵守的合約。

回傳型別的註記,就像一份規格書:「這個函式永遠只會回傳這三個特定字串之一。」如果你的實作試圖回傳任何其他東西,TypeScript 就會阻止程式碼編譯。你等於是在說:「我希望為這個承諾負責。」

當函式本身就是一個值

在 JavaScript 中,函式就像字串或數字一樣,是一種「值」。你可以把它們指派給變數、當作參數傳遞,以及從其他函式中回傳。TypeScript 可以為所有這些情境加上型別,而這正是它變得真正強大的地方。

// 定義一個函式應該長什麼樣子
type ArticleProcessor = (article: { title: string; content: string }) => string;

// 將這個型別用在變數上
let currentProcessor: ArticleProcessor;

// 指派一個符合該型別的函式
currentProcessor = (article) => {
  return `${article.title}: ${article.content.substring(0, 100)}...`;
};

這個洞見讓我對函式型別豁然開朗:當你像 ArticleProcessor 這樣定義一個函式型別時,你其實是在為「行為」建立一個樣板。 你在說:「任何符合這個型別的函式,都必須接收一個帶有 title 和 content 的 article 物件,並且必須回傳一個字串。」

這在處理事件處理器 (event handlers)、回呼函式 (callback functions),或是在函式被頻繁傳遞的函數式程式設計模式中,特別有用。你不再需要去猜測「這個 callback 到底需要什麼參數?」,型別會直接告訴你,你需要提供一個長什麼樣子的函式。

那箭頭函式呢?

我們目前所涵蓋的一切,對於箭頭函式 (arrow functions) 也完全適用。選擇使用一般函式還是箭頭函式,多半是風格上的考量,但有一個 TypeScript 特有的細節值得注意:

// 一般函式
function calculateEngagement(views: number, likes: number): number {
  return (likes / views) * 100;
}

// 箭頭函式 - 完全等價
const calculateEngagement = (views: number, likes: number): number => {
  return (likes / views) * 100;
};

// 簡短的箭頭函式
const formatPercentage = (value: number): string => `${value.toFixed(1)}%`;

對於箭頭函式,TypeScript 通常能從表達式中推斷出回傳型別,這讓簡短的函式寫起來更簡潔。但要小心——如果推斷出來的型別不是你所預期的,你可能正在製造隱晦的 bug。

總之,使用哪種風格取決於你團隊的程式碼慣例。TypeScript 對兩者一視同仁,提供的安全保證也完全相同。

宏觀來看:從「希望」到「確定」

變數和函式是任何程式的基礎建構模塊,而 TypeScript 透過加入型別安全,讓它們變得更加可靠。你不再需要「希望」你的變數含有正確種類的資料,或是「希望」你的函式收到正確的參數,取而代之的是,你得到了由編譯器強制執行的「保證」。

但當你開始持續使用 TypeScript 後,真正改變的是:你與你的程式碼之間的關係,發生了根本性的不同。 在寫 JavaScript 時,你總是在不斷地自我懷疑。「我記得處理 null 的情況了嗎?如果有人傳了字串而不是數字會怎樣?我能保證這個物件一定有我正要存取的屬性嗎?」

有了 TypeScript,這些問題在編譯時期就得到了解答,而不是在執行時期。你的 IDE 能提供更好的自動完成建議,因為它知道你的物件上有哪些屬性。重構變得不再那麼可怕,因為當你改變一個函式簽名時,TypeScript 能告訴你所有需要更新的地方。最重要的是,你在 bug 到達使用者手中之前,就已經捕捉到了它們。

此外,還有一個難以量化的心理上的好處:你會開始寫出更有自信的程式碼。 當你知道有 TypeScript 在背後支持你時,你會更願意去重構、更願意去做改動、也更願意去寫複雜的邏輯,因為你相信型別錯誤會立刻浮現,而不是在正式環境中才爆發。

下次當你在寫一個函式時,試著為它的參數加上 TypeScript 型別。你可能會驚訝地發現,一旦你開始思考你的函式到底應該接受哪些型別的資料,有多少潛在的邊界情況會變得顯而易見。那種豁然開朗的時刻——當你意識到你需要處理三個你之前沒考慮到的輸入情境時——正是 TypeScript 發揮其價值的最佳體現。


加入 E+ 成長計畫

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

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