[實作筆記] Google NAT 簡介與實作

前情提要

在打造安全的 VM 環境時,常常會遇到一個經典的矛盾點:
我們希望 VM 保持隱蔽,避免直接暴露於網際網路以減少安全風險,但同時又需要對外存取資源。
例如在開發或維運階段,像是執行 npm install、apt-get update,甚至提供給合作第三方的白名單 IP 等需求,往往都依賴外部下載與連線。

因此,我們的需求很明確:

  • VM 非必要時,不提供 Public IP。
  • VM 在發出 Request 時,需要擁有一個固定的對外 IP 地址。

傳統機房的解決方案

在傳統機房環境中,透過 NAT (Network Address Translation) 伺服器或防火牆設備,就能實現內部網路的安全管理。

內部流量管理: 內部 VM 使用私有 IP 位址,對外流量透過 NAT 進行轉換。
控制風險: 透過 NAT,內部伺服器可以訪問外部網路,但外部無法直接存取內部資源。
然而,傳統機房的設定通常需要額外的硬體設備,並增加了維護成本。

GCP 的解決方案 NAT

在 GCP 上,這個問題有一個優雅的解法:Cloud NAT。

無須公有 IP: Cloud NAT 允許私有 VM 不需配置公有 IP,就能存取外部網路。
集中管理: 透過 VPC 網路層級的 NAT 設定,簡化整體網路架構。
彈性擴展: Cloud NAT 可以根據流量自動擴展,減少單點故障風險。

NAT

實作筆記

步驟 1: 建立 VPC 網路

在 GCP 控制台中,前往 VPC 網路,選擇 建立 VPC 網路。
設定一個新的 VPC 網路,並確保 VM 的子網路設為 私有(Private)。這樣做可以保護 VM 不被直接暴露於公共網路。
實務上我選擇 default

步驟 2: 配置 Cloud NAT

前往 VPC 網路 > Cloud NAT,並選擇 建立 NAT 網關。
在設置過程中,選擇對應的子網路和路由(Route)設定。這樣可以確保 VM 在沒有 Public IP 的情況下仍能夠透過 NAT 網關訪問外部網路。
配置完成後,Cloud NAT 將會自動幫助 VM 處理對外的連線請求,而不需要直接公開 VM 的 IP。

步驟 3: 配置 Public IP(如果需要)

若要讓 VM 直接對外發送請求或開放服務,可以在創建 VM 時為其配置 Public IP。
在 Google Cloud Console 中創建虛擬機時,選擇 外部 IP 設為 靜態(static),這樣可以確保 Public IP 地址不會變動。
當 VM 配置了 Public IP,所有對外的請求將會直接通過這個 IP 進行。

心得

Cloud NAT 的設計,讓我們可以兼顧安全性與便利性。
關鍵優勢:

簡單配置: 不需像傳統 NAT 那樣繁複的硬體設置。
靈活性高: 無論是開發環境還是正式部署,都能輕鬆應對。
成本效益: 減少了維運成本,同時保護了內部資源。

參考

(fin)

[學習筆記] 里氏替換原則(Liskov Substitution Principle, LSP)

前言

OOP 中的五大原則之一—里氏替換原則,開發 OOP 的工程師應該或多或少都有聽過,
最近與同事討論後有新的體悟,特別記錄一下

1
若對某個型別 T 的物件 𝑥 能證明具有某個性質 𝑞(𝑥),那麼對於 T 的子型別 S 的物件 𝑦 同樣應該滿足 𝑞(𝑦)。
1
2
If a property 𝑞(𝑥) can be proven for objects 𝑥 of type 𝑇,
then 𝑞(𝑦) should also hold true for objects 𝑦 of type 𝑆, where 𝑆 is a subtype of 𝑇.

里氏替換原則(Liskov Substitution Principle, LSP)詳解
定義:
里氏替換原則由 Barbara Liskov 在 1987 年提出,
是 SOLID 原則中的 “L”。它強調 子類(Subclass) 應該能夠替換其 父類(Base Class) 而不影響程式的正確性或行為。

核心概念:
“任何使用父類的地方,都應該能夠使用其子類,而不會影響系統的功能。”

主要原則

子類應該擁有父類的所有行為特性。
子類不應改變父類方法的預期行為。
子類可以擴展父類的功能,但不能削弱或改變父類的功能。

違反 LSP 的常見問題:
替換後引發錯誤: 子類重寫某個方法,導致使用者無法正常使用原本的功能。
返回值不一致: 子類方法返回值與父類期望的類型不同。
拋出未預期的異常: 子類新增或拋出父類未預期的異常。

舉例說明,錯誤示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Bird {
fly(): void {
console.log('Flying...');
}
}

class Penguin extends Bird {
// Penguins can't fly!
fly(): void {
throw new Error("Penguins can't fly!");
}
}

function makeBirdFly(bird: Bird) {
bird.fly();
}

// 當傳入 Penguin 時,會引發錯誤
const penguin = new Penguin();
makeBirdFly(penguin); // Throws an error!

違反原因:

在 makeBirdFly 函數中,傳入 Penguin 子類後,原本預期的行為(鳥會飛)被破壞,導致異常發生,違反 LSP。

正確示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Bird {
move(): void {
console.log('Moving...');
}
}

class FlyingBird extends Bird {
fly(): void {
console.log('Flying...');
}
}

class Penguin extends Bird {
swim(): void {
console.log('Swimming...');
}
}

function makeBirdMove(bird: Bird) {
bird.move();
}

const penguin = new Penguin();
makeBirdMove(penguin); // 正常運行,無異常

修正方式

將 Bird 分解為更細的抽象類別(如 FlyingBird)。
確保所有子類的行為符合其父類的行為約束。
實踐 LSP 的要點:

  • 正確使用繼承: 只有在 “是某種(is a)” 的關係中使用繼承;例如:Dog 是 Animal 的一種(我們都不認為這是一個好例子)。
  • 避免強制行為: 子類不應強迫覆蓋父類的方法,除非行為一致。
  • 使用介面: 如果子類具有不同行為,可使用多個接口來定義不同能力。

總結:
LSP 促使開發者在繼承和多型設計中保持一致性和正確性,保證系統的可擴展性和穩定性。
遵循 LSP 可以避免許多因繼承導致的潛在問題,使代碼更健壯且易於維護。

我們的一些關鍵 Q&A

Q.LSP 的意思是不要用 override ?
A.
不是的,LSP(里氏替換原則)並不是要求完全避免使用 override,
而是要正確使用 override。核心在於: 子類覆寫父類方法時,不能破壞父類的行為預期。

Q. 要正確使用 override 的原則是什麼?
A. 子類應維持父類方法的語義一致性
覆寫後的行為應與父類保持一致,並且在替換後不會引發錯誤。

Q. 有更具體的說明嗎 ?
A. 不要改變方法的輸入輸出參數規格
遵循開放封閉原則(Open/Closed Principle),開放擴展,封閉修改。
方法的參數類型不能比父類方法的參數更具限制性。
方法的返回類型不能比父類方法的返回類型更具擴展性。

Q.
以你的例子來說Penguin 的確不會 fly , 所以應該拋出錯誤
但是這樣就違反了 LSP ,所以我們的建議是將 fly 再抽出成為介面?
A.
沒錯!這是關鍵點了。解決這種情況的最佳方式是使用介面(Interface)來抽象行為,而不是在基類中定義所有可能不適用於子類的行為。

子類應擴展父類的功能,而不是修改父類的行為。

正確的覆寫示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Animal {
makeSound(): string {
return "Some sound";
}
}

class Dog extends Animal {
override makeSound(): string {
return "Bark";
}
}

function playSound(animal: Animal) {
console.log(animal.makeSound());
}

const dog = new Dog();
playSound(dog); // Output: "Bark" (符合父類預期)

說明:
Dog 覆寫了 makeSound(),但返回類型和行為保持一致,替換 Animal 後,系統的正確性不受影響。

違反 LSP 的覆寫示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Rectangle {
constructor(protected width: number, protected height: number) {}

setWidth(width: number) {
this.width = width;
}

setHeight(height: number) {
this.height = height;
}

getArea(): number {
return this.width * this.height;
}
}

class Square extends Rectangle {
// 覆寫後改變了行為,違反 LSP
setWidth(width: number) {
this.width = width;
this.height = width; // 強制將高度設為相等
}

setHeight(height: number) {
this.height = height;
this.width = height; // 強制將寬度設為相等
}
}

function testRectangle(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(10);
console.log(rect.getArea()); // 預期:50
}

const square = new Square(0, 0);
testRectangle(square); // Output: 100 (違反父類預期)

說明:
Square 覆寫了 setWidth 和 setHeight 方法,但改變了 Rectangle 的行為。
傳入 Square 後,計算面積的結果與 Rectangle 的預期不同,這破壞了 LSP。

兩個例子的差異在於
Dog 的覆寫行為符合父類預期,Square 改變父類邏輯(強制高寬相等),破壞原有功能,違反 LSP。

總結

可以使用 override,但要謹慎處理。
確保子類的行為符合父類的預期,不會引入不一致或異常行為。
遵守 LSP 能保證系統在多型使用時的穩定性和可預測性。

對「少用繼承多用組合」的體悟多了一層,如果發現繼承了父類的方法,
但是修改了行為(拋錯誤、改變本來沒有改動到的屬性、長出新的邏輯分更支…),這就是一種壞味道。  
大部份應該都可以透過介面排除這個問題。

(fin)

[實作筆記] Macbook dotnet 8 升級 dotnet 9

前情提要

dotnet 9 官宣囉 ! 雖然不是 lts 版本,但是仍然有許多有趣的新東西,先昇級並記錄一下。

小常識

.NET Runtime 和 .NET SDK 主要的差別在於它們的用途和所包含的工具:

.NET Runtime:

僅包含運行 .NET 應用所需的基本庫和執行環境。
適合只需要執行已編譯應用程序的情況,像是使用者在安裝應用後直接執行時。
不包含編譯、打包或開發所需的工具和命令。
.NET SDK (Software Development Kit):

包含 .NET Runtime,以及用來開發、編譯、測試和打包 .NET 應用的工具和命令。
包括開發者常用的命令,如 dotnet build(編譯)、dotnet publish(發布)、dotnet test(測試)等。
必須安裝 SDK 才能進行應用程式的開發,而不僅僅是執行。

簡單來說:

如果你只是想執行 .NET 應用,安裝 Runtime 即可。如果你要開發或修改 .NET 應用,則需要 SDK。

安裝步驟

https://dotnet.microsoft.com/en-us/download/dotnet/9.0
選擇 SDK 9.0.100 (左側) > macOS Installers > Arm64

我曾經裝到 X64,安裝過程沒有失敗,但是 dotnet version 仍為 8.0.100

檢查

❯ dotnet –version
9.0.100
❯ dotnet –list-sdks
8.0.100 [/usr/local/share/dotnet/sdk]
8.0.101 [/usr/local/share/dotnet/sdk]
9.0.100 [/usr/local/share/dotnet/sdk]

專案升級

專案有許多套件有 Warning 先進行 restore

❯ dotnet restore Marsen.NetCore.Dojo.Integration.Test.sln

❯ dotnet restore Marsen.NetCore.Dojo.Integration.Test.sln
(中略)
在 24.4 秒內還原 成功但有 9 個警告

IDE 我是使用 Rider

Tools → NuGet → Manage NuGet Packages for Solution…

把能昇級的都昇一昇,再執行測試

測試摘要: 總計: 467, 失敗: 0, 成功: 466, 已跳過: 1, 持續時間: 4.7 秒
在 6.5 秒內建置 成功但有 9 個警告

Tools → NuGet → Upgrade Packages in Solution…

再執行測試

測試摘要: 總計: 467, 失敗: 0, 成功: 466, 已跳過: 1, 持續時間: 4.7 秒
在 6.5 秒內建置 成功但有 9 個警告

警告如下

warning NU1903: 套件 ‘System.Text.RegularExpressions’ 4.3.0 具有已知的 高 嚴重性弱點
warning NU1903: 套件 ‘Newtonsoft.Json’ 9.0.1 具有已知的 高 嚴重性弱點
warning CS0618: ‘SqlConnection’ 已經過時: ‘Use the Microsoft.Data.SqlClient package instead.’
warning SYSLIB0051: ‘Exception.Exception(SerializationInfo, StreamingContext)’ 已經過時

高風險可以透過 Nuget 確認有沒有新版本的或 patch

SYSLIB0051: Legacy serialization support APIs are obsolete 基本上都是在繼承舊版 Exception 時建立的方法
可以刪除,但這次先標已過時標籤後並保留,相關程式都沒有呼叫這些方法,所以不會引發新的警告。

warning CS0618: ‘SqlConnection’ 已經過時: ‘Use the Microsoft.Data.SqlClient package instead.’
很簡單,依據他的建議,改用 using Microsoft.Data.SqlClient; 即可。
當然要先從 Nuget 安裝 package

處理完警告後,修改所有csproj內的 TargetFramework 標籤內容如下:

1
<TargetFramework>net9.0</TargetFramework>

CICD 相關的設定也要改為 dotnet 9.0

像是 .github/workflows 腳本中的 dotnet-version

1
2
3
4
5
steps:
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: '9.0.x'

或是 sonarscan-dotnet 中使用的版本(這部份如果原始 repo 尚未更新,可以試著自已更新看看)

1
2
3
- name: SonarCloud Scan
# support dotnet9.0
uses: marsen/[email protected]

(fin)

[實作筆記] 個人經歷 STAR 試寫

前情提要

STAR 是一種行之有效的行為面試方法,全名為 Situation(情境)、Task(任務)、Action(行動) 和 Result(結果)。
這個框架幫助應徵者組織答案,描述過去經驗時更有條理,清楚展示個人在特定情境下的行動力及成效。
具體來說:

  • Situation(情境):描述事情發生的背景或狀況,讓聽者了解事件的前因後果。
  • Task(任務):指出自己在該情境中所負責的任務或需完成的目標。
  • Action(行動):詳細說明為了完成任務所採取的具體步驟和行動。
  • Result(結果):描述行動的成果,最好是能量化的結果或正面的

透過 STAR 方法,可以清晰地展示個人職涯解決問題的能力及帶來的正面影響。

個人經歷 STAR 試寫

公司名 情境 任務 行動 結果
N 社. 團隊負責開發維護跨國電商系統,面臨整合不同市場需求的挑戰。 確保開發過程的順利,協調跨團隊成員,並確保系統的本地化符合各地要求。並最需要回歸到 ONE CODE BASE 實施敏捷方法,善用分支策略,進行代碼審查和測試,引入 TDD 和 CI/CD 流程,提升開發效率。 成功推動三個國家的服務上線,縮短開發時間 30%,獲得客戶的積極反饋,促進未來業務擴展和合作機會。在全公司中是最少發生合併衝突的開發團隊。
N 社. 產品 24/7 監控系統的實施,以確保服務的穩定性和可用性。 設計並實施監控解決方案,確保系統在出現故障時能迅速響應,並減少停機時間。 分析系統需求並適當排序,建置監控工具,警報系統與流程。並依序修復異常 成功降低系統故障響應時間 90%,顯著提高服務可靠性,提升客戶滿意度。
N 社 & B 社產學合作 與中華大學及建中學校進行產學合作項目,推動學生的實踐經驗和技術分享。 指導 B 社學生,確保項目能按時完成,達到教育和實踐的雙重目標。 舉辦技術工作坊,提供指導並進行項目評估,促進學生在實際環境中的學習。 學生在項目中獲得寶貴經驗,成功展示成果並就業,促進學校與業界的合作關係。
N 社 在 2018 年成為團隊技術領導,面對管理與技術職能切換的挑戰。 與跨部門成員重組成跨國團隊 組織讀書會工作坊,促進團隊成員達成共識,並引入相關的軟體開發工具和流程。 成功建立了一支開發團隊,如期如質的交付產品。
C 社 擔任開發工程師,負責0到1設計和開發金融相關的應用程式和服務。 建立多產品線與後台以功能符合內外用戶需求並持續改進。 利用用戶反饋進行產品迭代,並推動新功能的開發,使用敏捷開發方法提升效率。導入版本控制以解決多人協作問題。 成功推出了數個新的功能,顯著提高了用戶滿意度和產品的使用率。整體營收提昇 20 倍以上(2000%↑)

(fin)

[學習筆記] TypeScript tsconfig 設定備忘錄

前情提要

tsconfig.json 文件是一個 TypeScript 專案的配置文件, 它位於根目錄中, 並定義了專案的編譯器選項。
提供給開發人員輕鬆配置 TypeScript 編譯器, 並確保專案的程式碼在不同的環境中始終保持一致。
你可以使用 tsc --init 指令自動產生。
也可以參考 Matt Pocock 的 TSConfig Cheat Sheet

TSConfig 有上百個配置, 本文將用來重點記錄一些相關的配置

本文

2024/10 Matt Pocock 的建議配置

相關說明請參考原文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,

/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,

/* If transpiling with TypeScript: */
"module": "NodeNext",
"outDir": "dist",
"sourceMap": true,

/* AND if you're building for a library: */
"declaration": true,

/* AND if you're building for a library in a monorepo: */
"composite": true,
"declarationMap": true,

/* If NOT transpiling with TypeScript: */
"module": "preserve",
"noEmit": true,

/* If your code runs in the DOM: */
"lib": ["es2022", "dom", "dom.iterable"],

/* If your code doesn't run in the DOM: */
"lib": ["es2022"]
}
}

files

預設值為 false, 如果只有少量的 ts 檔, 很適合設定這個files
但實務上更常用 includeexclude 搭配,
兩者都可使用 wildcard , exclude 擁有較高的優先級,
例如下面的設定 src/.test 底下的檔案將不會被編譯:

1
2
3
4
5
6
7
8
9
{
"compilerOptions": {},
"include": ["src/**/*"],
"exclude": ["src/test/**/*"]
"files": [
"a_typescript_file.ts",
"other_ts_code.ts"
]
}

Compiler Options

這部份是 tsconfig 相關設定的主體, 有需要時來這裡查就好
下面的例子是一些常用的設定說明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"compilerOptions": {
// esModuleInterop: 這個選項使 TypeScript 能夠更好地與 CommonJS 模組兼容, 允許以 ES 模組方式導入 CommonJS 模組。
"esModuleInterop": true,
// skipLibCheck: 當設置為 true 時, TypeScript 將跳過對聲明檔案(.d.ts)的型別檢查, 可以加快編譯速度, 尤其是在大型專案中。
"skipLibCheck": true,
// target: 指定編譯後的 JavaScript 代碼的 ECMAScript 版本。這裡設定為 ES2022, 這意味著生成的代碼將使用該版本的新特性。
"target": "es2022",
// allowJs: 此選項允許在 TypeScript 專案中使用 JavaScript 檔案, 這對於逐步遷移到 TypeScript 的專案特別有用。
"allowJs": true,
// resolveJsonModule: 使 TypeScript 能夠導入 JSON 檔案, 並將其視為模組。
"resolveJsonModule": true,
// moduleDetection: 設置為 force 使 TypeScript 將所有檔案視為模組, 避免了使用全局變數引起的錯誤。
"moduleDetection": "force",
// isolatedModules: 每個檔案將被獨立編譯, 這對於使用 Babel 或其他工具的場景特別重要。
"isolatedModules": true,
// verbatimModuleSyntax: 強制使用類型專用的導入和導出, 使 TypeScript 更加嚴格, 這樣有助於在編譯時優化生成的代碼。
"verbatimModuleSyntax": true
}
}

Watch Option

watchOptions 用於配置 TypeScript 在開發過程中如何監控文件變更。
這些選項主要用於改善開發效率, 當監控的文件或目錄發生變化時, TypeScript 會自動重新編譯或執行其他指定操作。
這對於開發階段非常重要, 因為它允許開發者即時查看更改的影響, 而不必手動重新編譯代碼。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"compilerOptions": {},
"watchOptions": {
// watchFile: 用於設定 TypeScript 監控單個檔案的方式。
// fixedPollingInterval: 每隔固定時間檢查所有檔案是否變更。
// priorityPollingInterval: 根據啟發式方法, 對某些類型的檔案進行優先輪詢。
// dynamicPriorityPolling: 使用動態隊列, 較少修改的檔案將會被較少檢查。
// useFsEvents (預設): 嘗試使用操作系統/檔案系統的原生事件來監控檔案變更。
// useFsEventsOnParentDirectory: 嘗試監聽檔案父目錄的變更事件, 而不是直接監控檔案。
"watchFile": "useFsEvents", // 預設為使用文件系統事件來監控檔案變更。

// watchDirectory: 用於設定 TypeScript 監控整個目錄的方式。
// fixedPollingInterval: 每隔固定時間檢查所有目錄是否變更。
// dynamicPriorityPolling: 使用動態隊列, 較少修改的目錄將會被較少檢查。
// useFsEvents (預設): 嘗試使用操作系統/檔案系統的原生事件來監控目錄變更。
"watchDirectory": "dynamicPriorityPolling", // 使用動態優先級輪詢, 檢查變更較少的目錄次數較少。

// 設置檔案或目錄的輪詢間隔時間(以毫秒為單位), 適用於輪詢策略。
"pollingInterval": 2000, // 設置為每 2000 毫秒輪詢一次檔案或目錄變更。

// 設置是否監控目錄及其所有子目錄
"recursive": true // 設置為 true 以監控所有子目錄的變更。
}
}

Type Acquisition

Type Acquisition 主要適用於 JavaScript 專案。
在 TypeScript 專案中, 開發者必須明確地將型別包含在專案中。
相對地, 對於 JavaScript 專案, TypeScript 工具會自動在後台下載所需的型別, 並將其儲存在 node_modules 以外的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"compilerOptions": {},
"typeAcquisition": {
// enable: 啟用自動獲取 JavaScript 專案的型別檔案 (.d.ts)。
"enable": true,

// include: 指定自動獲取型別檔案的庫。
// 例如, 在這裡指定了 jQuery 和 lodash, TypeScript 將自動獲取它們的型別檔案。
"include": ["jquery", "lodash"],

// exclude: 指定不需要自動獲取型別檔案的庫。
// 這裡指定了 "some-legacy-library", 即使它存在於專案中, TypeScript 也不會嘗試獲取它的型別檔案。
"exclude": ["some-legacy-library"],

// disableFilenameBasedTypeAcquisition: 禁用基於檔案名稱自動獲取型別檔案的功能。
// 當設置為 true 時, TypeScript 不會基於檔案名稱來推測並下載型別檔案。
"disableFilenameBasedTypeAcquisition": false
},
}

參考

(fin)

[學習筆記] Omit 與 Pick 的 Distributive 版本:解決 TypeScript Utility Types 的陷阱

前情提要

在 TypeScript 中,Omit 和 Pick 是廣受喜愛的 Utility Types,
它們允許你從現有型別中排除或選擇特定的屬性來創建新型別。
參考我之前的文章

本文

基礎示範:Omit 的使用

我們先來看個簡單的例子,假設我們有一個 Game 型別,其中包含 id、name 和 price 三個屬性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Game = {
id: string; // 遊戲的唯一識別碼
name: string; // 遊戲名稱
price: number; // 遊戲價格
};

type GameWithoutIdentity = Omit<Game, "id">;

const game: GameWithoutIdentity = {
//id: "1", // ❗ 編譯錯誤:'price' 不存在於 'GameWithoutIdentity' 型別中
name: "The Legend of Zelda",
price: 59.99,
};

Omit 可以幫助我們排除 id 屬性並創建新型別 GameWithoutIdentity
這在單一型別中運行良好,但當我們引入 Union Types 時,就有一些細節值得討論。

問題:Union Types 中的 Omit 行為

假設我們有三個型別:Game、VideoGame 和 PCGame。
它們的 id 和 name 屬性相同,但每個型別都有其獨特的屬性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

type Game = {
id: string; // 遊戲的唯一識別碼
name: string; // 遊戲名稱
};

type VideoGame = {
id: string; // 遊戲的唯一識別碼
name: string; // 遊戲名稱
platform: string; // 遊戲平台
genre: string; // 遊戲類型
};

type PCGame = {
id: string; // 遊戲的唯一識別碼
name: string; // 遊戲名稱
systemRequirements: string; // 系統需求
hasDLC: boolean; // 是否有 DLC
};

當我們將這三個型別聯合起來形成 GameProduct 並嘗試使用 Omit 排除 id 時,結果卻不是我們預期的。

1
2
type GameProduct = Game | VideoGame | PCGame;
type GameProduct = Omit<GameProduct, "id">;

你可能期望 GameProductWithoutId 是三個型別排除 id 屬性的 Union Type,但實際上,我們只得到了這樣的結構:

1
2
3
type GameProduct = {
name: string;
}

這表示 Omit 在處理 Union Types 時,並沒有對每個聯合成員單獨操作,而是將它們合併成了一個結構。

原因分析

這種行為的根源在於,OmitPick 不是 DistributiveUtility Types
它們不會針對每個 Union Type 成員進行個別操作,而是將 Union Type 視為一個整體來操作。
因此,當我們排除 id 屬性時,它無法處理每個成員型別中的不同屬性。

這與其他工具型別如 PartialRequired 不同,這些工具型別可以正確地處理 Union Types,並在每個成員上應用。

1
2
3
type PartialGameProduct = Partial<GameProduct>;
// 正確地給出了聯合型別的部分化版本
type PartialGameProduct = Partial<Game> | Partial<VideoGame> | Partial<PCGame>;

解決方案:Distributive Omit 與 Distributive Pick

要解決這個問題,我們可以定義一個 Distributive 的 Omit,這個版本會針對 Union Type 的每個成員進行操作。

1
2
3
type DistributiveOmit<T, K extends PropertyKey> = T extends any
? Omit<T, K>
: never;

使用 DistributiveOmit 後,我們可以正確地得到想要的結果:

1
2
3
type GameProductWithoutId = DistributiveOmit<GameProduct, "id">;
// Hover 正確地給出了聯合型別的必填化版本
//type GameProductWithoutId = Omit<Game, "id"> | Omit<VideoGame, "id"> | Omit<PCGame, "id">

這將生成以下結構:

1
2
3
4
5
// 所以
type GameProductWithoutId =
{ name: string } |
{ name: string; platform: string; genre: string } |
{ name: string; systemRequirements: string; hasDLC: boolean };

現在,GameProductWithoutId 正確地成為了每個型別的 Union Type,並且成功地排除了 id 屬性。

Distributive Pick

類似的,我們也可以定義一個 Distributive 的 Pick:

1
2
3
type DistributivePick<T, K extends keyof T> = T extends any
? Pick<T, K>
: never;

這個 Distributive 版本的 Pick 確保了你所選擇的屬性實際存在於你正在操作的型別中,與內建的 Pick 行為一致。

總結

OmitPick 雖然在單一型別中表現良好,但在 Union Types 中,
它們並不是 Distributive 的,這可能導致意想不到的結果。
我們可以創建 DistributiveOmitDistributivePick
使它們能夠針對每個 Union Type 成員單獨進行操作,從而獲得更為預期的結果。
如果你在專案中遇到這類問題,記得可以考慮使用自定義的 Distributive 版本來處理 Union Types,這樣可以避免踩雷!

(fin)

[學習筆記] TypeScript 的 Narrowing Types 與 Boolean 的例外

前情提要

在使用 TypeScript 時,我們常常會利用條件語句來收窄型別(Narrowing Types),確保程式邏輯的正確性。
例如:
Narrowing Types的例子:使用 typeof

1
2
3
4
5
6
7
8
9
const processValue = (value: string | number) => {
if (typeof value === "string") {
// 在這裡 TypeScript 已經收窄為 string,所以可以使用 .toUpperCase 的方法
console.log(`String value: ${value.toUpperCase()}`);
} else {
// 在這裡 TypeScript 已經收窄為 number,所以可以使用 .toFixed 的方法
console.log(`Number value: ${value.toFixed(2)}`);
}
};

說明

在這個例子中,我們定義了一個 processValue 函數,它接受一個 string 或 number 類型的參數。
使用 typeof 來檢查 value 的類型後,TypeScript 會自動進行 Narrowing Types:
typeof value === "string" 時,TypeScript 將 value 的類型自動收窄為 string,
所以我們可以安全地使用字串方法,如 toUpperCase()
當 else 條件觸發時,value 類型已經收窄為 number,因此我們可以使用 number 專有的方法,如 toFixed()。
這樣的收窄機制能夠在多態的情況下提高程式的安全性和可讀性,讓開發者更清楚在不同條件下該如何操作不同類型的值。

主文

然而,在一些特定情況下,TypeScript 的 Narrowing Types 並不如我們預期的那麼靈活。
比如說 Boolean() 函數進行條件檢查時,TypeScript 不會像其他檢查方法那樣進行 Narrowing Types。

1
2
3
4
5
6
7
8
9
10
11
const NarrowFun = (input: string | null) => {
if (!!input) {
input.toUpperCase(); // 這裡 TypeScript 收窄為 string
}
};

const NotNarrowFun = (input: string | null) => {
if (Boolean(input)) {
input.toUpperCase(); // 這裡 TypeScript 沒有收窄,仍然是 string | null
}
};

在這段程式碼中,我們希望透過條件語句來檢查 input 是否為 null。
使用 !!input 時,TypeScript 會正確地將 input 的 Narrowing Types為 string,
但當我們改用 Boolean(input) 時,TypeScript 並沒有收窄,仍然認為 input 可能是 string | null。

為什麼會這樣?

Boolean 函數的行為

Boolean 是 JavaScript 的一個內建函數,它接受任何值並將其轉換為 true 或 false,
基於 JavaScript 的「true」或「false」邏輯。
然而,TypeScript 並不視 Boolean(input) 作為一個能收窄類型的操作。
它只將 Boolean(input) 視為一個普通的布林值返回,不會對 input 進行更深入的類型推斷。

在 TypeScript 中,Boolean 函數的定義是這樣的:

1
2
3
4
5
6
interface BooleanConstructor {
new(value?: any): Boolean;
<T>(value?: T): boolean;
}

declare var Boolean: BooleanConstructor;

如你所見,Boolean 是一個接受任何類型 (any) 的值作為輸入,並返回 boolean 類型的結果。
它會將輸入轉換為布林值 (true 或 false),但並不提供關於輸入值的具體類型資訊。
因此,TypeScript 只知道返回的是一個 boolean,而無法推斷 input 的原始類型是否已經被過濾(例如從 string | null 到 string)。

缺乏類型推斷

在 Boolean(input) 中,TypeScript 僅知道 input 被轉換為 true 或 false,
但它不會從中推斷 input 的實際類型。
因此,TypeScript 並沒有收窄 input 為 string 或 null。
這就是為什麼即使你在 if(Boolean(input)) 內部,input 依然是 string | null,而不是單純的 string

1
2
3
if (Boolean(input)) {
console.log(input); // TypeScript 仍視 input 為 string | null
}

類型收窄的條件

TypeScript 進行類型收窄的條件是根據語法或邏輯條件來進行推斷,
例如 typeofinstanceof、比較操作 (==, ===) 等。
這些條件能幫助 TypeScript 推斷出更具體的類型。
當你使用 typeof input === “string” 時,TypeScript 可以自動將 input 收窄為 string,
因為這是一個明確的類型檢查:

1
2
3
if (typeof input === "string") {
console.log(input); // 現在 input 是 string 類型
}

為什麼 !!input 能收窄

與 Boolean(input) 不同,!!input 能夠收窄類型。
因為這個雙重否定操作 (!!) 是 JavaScript 的一個常見模式,它會將任意值轉換為布林值。
由於 TypeScript 能識別這個模式,當你寫 if(!!input) 時,
TypeScript 可以推斷 input 是 truthy,並將 null 或 undefined 排除在外。
因此,input 被收窄為一個具體的類型(在這裡是 string)。

1
2
3
if (!!input) {
console.log(input); // input 被收窄為 string
}

替代方案:自定義判斷函數

如果我們需要更加靈活的 Narrowing Types,可以考慮使用自定義的判斷函數。
例如,我們可以創建一個 isString 函數來明確檢查某個值是否為字串,這樣就可以正確進行 Narrowing Types。

1
2
3
4
5
6
7
8
9
const isString = (value: unknown): value is string => {
return typeof value === "string";
};

const myFunc = (input: string | null) => {
if (isString(input)) {
console.log(input); // 這裡 TypeScript 收窄為 string
}
};

在這段程式碼中,我們定義了一個 isString 函數,它不僅進行類型判斷,還告訴 TypeScript 當返回 true 時,value 確實是 string。
這樣的函數可以幫助我們更好地進行 Narrowing Types。

另一個常見的例子:filter(Boolean)

filter(Boolean) 是 JavaScript 中常用的一個語法糖,用來過濾掉 falsy 值(如 null、undefined、0 等)。
但在 TypeScript 中,這個模式無法進行 Narrowing Types。

1
2
3
4
const arr = [1, 2, 3, null, undefined];

// 結果類型仍然包含 null 和 undefined
const result = arr.filter(Boolean);

在這裡,TypeScript 沒有收窄 result 的類型,它仍然認為結果可能包含 null 和 undefined。
要解決這個問題,我們可以自定義一個過濾函數,來正確處理這些類型。

1
2
3
4
5
6
7
const filterNullOrUndefined = <T>(value: T | null | undefined): value is T => {
return value !== null && value !== undefined;
};

const arr = [1, 2, 3, null, undefined];

const result = arr.filter(isNotNullOrUndefined); // 現在類型是 number[]

這樣,我們就能確保結果陣列只包含 number,而不再包含 null 和 undefined。

小結

雖然 Boolean() 是 JavaScript 中常見的布林判斷工具,但在 TypeScript 中,它無法像其他判斷語句一樣進行 Narrowing Types。
我們可以透過自定義類型判斷函數,來精確地告知 TypeScript 在特定條件下的類型,從而實現更強大和靈活的類型檢查。

參考

(fin)

[學習筆記] 使用 Discriminated Unions 解決多狀態問題

前情提要

在日常開發中,我們常需要定義不同狀態下的數據結構,這類需求通常涉及到多個狀態及其對應的屬性。
在 TypeScript 中,如果不加以控制,這些狀態容易變成一個充滿可選屬性的複雜物件,導致程式碼難以管理。
我們有一種更優雅的解決方案 —— Discriminated Unions
它在 TypeScript 中幫助我們避免常見的「bag of optionals」的問題。

主文

假設我們正在開發一個付款流程,並需要一個 PaymentState 類型來描述付款狀態:

1
2
3
type PaymentState = {
status: "processing" | "success" | "failed";
};

此時,我們還需要根據不同狀態保存資料或錯誤訊息,
因此我們為 PaymentState 類型新增了可選的 receiptUrl 和 error 屬性:

1
2
3
4
5
type PaymentState = {
status: "processing" | "success" | "failed";
errorMessage?: string;
receiptUrl?: string;
};

這個定義表面上看起來可行,但在實際使用時容易出現問題。
假設我們定義了一個渲染 UI 的函式 renderUI:

1
2
3
4
5
6
7
8
9
10
11
12
13
const renderUI = (state: PaymentState) => {
if (state.status === "processing") {
return "Payment Processing...";
}

if (state.status === "failed") {
return `Error: ${state.errorMessage.toUpperCase()}`;
}

if (state.status === "success") {
return `Receipt: ${state.receiptUrl}`;
}
};

TypeScript 提示 state.errorMessage 可能是 undefined,
這意味著我們在某些狀態下無法安全地操作 errorMessage 或 receiptUrl 屬性。
這是因為這兩個屬性是可選的,並且沒有和 status 明確地綁定。
這就造成了類型過於鬆散

解決方案:Discriminated Unions

為了解決這個問題,我們可以使用 Discriminated Unions。
這種模式可以將多個狀態拆分為具體的物件,並通過共同的 status 屬性來區分每個狀態。

首先,我們可以將每個狀態單獨建模:

1
2
3
4
type State =
| { status: "processing" }
| { status: "error"; errorMessage: string }
| { status: "success"; receiptUrl: string };

這樣一來,我們就能明確地將 errorMessage 屬性與 error 狀態綁定,
並且 receiptUrl 屬性只會出現在 success 狀態下。
當我們在 renderUI 函式中使用這些屬性時,TypeScript 會自動根據 status 來縮小類型範圍:

1
2
3
4
5
6
7
8
9
10
11
12
13
const renderUI = (state: State) => {
if (state.status === "processing") {
return "Processing...";
}

if (state.status === "error") {
return `Error: ${state.errorMessage.toUpperCase()}`;
}

if (state.status === "success") {
return `Receipt: ${state.receiptUrl}`;
}
};

TypeScript 現在能夠根據 status 確保 errorMessage 在 error 狀態下是一個字串,
並且 receiptUrl 只會在 success 狀態出現。
這大大提高了我們程式碼的安全性和可讀性。

進一步優化:提取類型別名

為了讓程式碼更加清晰,我們可以將每個狀態定義為單獨的類型別名:

1
2
3
4
5
type LoadingState = { status: "processing" };
type ErrorState = { status: "error"; errorMessage: string };
type SuccessState = { status: "success"; receiptUrl: string };

type State = LoadingState | ErrorState | SuccessState;

這樣的結構不僅清晰易讀,也方便日後的擴展。
如果我們需要增加其他狀態,比如 idle 或 cancelled,只需要新增對應的類型別名即可,保持程式碼的擴展性和一致性。

小結

Discriminated Unions 是 TypeScript 中處理多狀態情境的強大工具,特別適合用來解決「可選屬性的大集合」問題。它能夠:

確保每個屬性和狀態間的強關聯,減少錯誤和不一致性。
提升程式碼的可讀性和維護性,並且有助於擴展性。
讓 TypeScript 自動推斷正確的屬性類型,避免不必要的空值檢查。
當你發現程式碼中有過多的可選屬性且沒有強烈關聯時,考慮使用 discriminated unions 來重構並簡化你的類型定義。

參考

(fin)

[學習筆記] 探索 TypeScript 的 Template Literals

前言

什麼是 Template Literals 類型?
在 TypeScript 中,Template Literals 類型是 ES6 引入的一個強大的功能。
Template literals ,主要用來加強字串的操作,它提供了更靈活和易讀的字串插值方式,
這不僅提高了代碼的可讀性,結合上 TypeScript 還可以增加了類型安全性
本篇提供大量範例與適用場景以供測試,有任何問題歡迎提出。

基礎應用(JS/TS 都適用)

字串插值 (String Interpolation)

傳統的字串拼接需要用 + 號,使用 Template Literals 可以讓拼接更簡單。

1
2
3
4
5
6
7
8
9
10
const name = "Lin";
const age = 30;

// 傳統拼接
const message = "My name is " + name + " and I am " + age + " years old.";

// Template Literals
const message2 = `My name is ${name} and I am ${age} years old.`;

console.log(message2);

多行字串 (Multi-line Strings)

Template literals 支援多行字串,可以避免使用 \n 或其他換行符號。

1
2
3
4
5
6
7
8
const poem = `
Roses are red,
Violets are blue,
Sugar is sweet,
And so are you.
`;

console.log(poem);

內嵌表達式 (Embedded Expressions)

可以在字串中插入任意的 JavaScript 表達式,例如函數調用、運算等。

1
2
3
4
const a = 5;
const b = 10;

console.log(`The sum of ${a} and ${b} is ${a + b}`);

條件判斷 (Conditional Statements)

結合三元運算符或簡單的 if 判斷,可以在字串中靈活地處理條件。

1
2
3
4
const loggedIn = true;
const message = `You are ${loggedIn ? "logged in" : "not logged in"}.`;

console.log(message);

標記模板字串 (Tagged Templates)

標記模板允許你在插值之前處理字串,可以用於處理國際化、多語系或安全操作(如避免 XSS 攻擊)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function sanitizeHTML(literals, ...values) {
let result = "";
literals.forEach((literal, i) => {
const value = values[i] ? String(values[i]).replace(/</g, "&lt;").replace(/>/g, "&gt;") : '';
result += literal + value;
});
return result;
}

const userInput = "<script>alert('Hacked!')</script>";
// call function use Tagged Template Literal
const safeOutput = sanitizeHTML`<div>${userInput}</div>`;

console.log(safeOutput); // <div>&lt;script&gt;alert('Hacked!')&lt;/script&gt;</div>

動態生成 HTML 或模版字串

當需要動態生成 HTML 或動態內容時,使用 Template Literals 可以讓結構更清晰。

1
2
3
4
5
6
7
8
9
const list = ["Apple", "Banana", "Cherry"];

const html = `
<ul>
${list.map(item => `<li>${item}</li>`).join('')}
</ul>
`;

console.log(html);

進階應用 with Type

動態生成 URL 路徑

在構建 API 或動態路徑時,Template Literals 結合 TypeScript 類型檢查可以確保參數類型正確,避免拼接錯誤。

1
2
3
4
5
6
7
8
9
10
11
type Endpoint = "/users" | "/posts" | "/comments";

function createURL(endpoint: Endpoint, id: number): string {
return `https://api.example.com${endpoint}/${id}`;
}

const url = createURL("/users", 123);
console.log(url); // https://api.example.com/users/123

// 若傳入不存在的路徑,TypeScript 會報錯
createURL("/invalid", 123); // Error: Argument of type '"invalid"' is not assignable to parameter of type 'Endpoint'.

可以作開發中的路由檢查。

1
2
3
4
5
6
7
8
9
10
11
12
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type Route = `/api/${string}`;

function request(method: HTTPMethod, route: Route): void {
console.log(`Sending ${method} request to ${route}`);
}

request("GET", "/api/users"); // 正確
request("POST", "/api/posts"); // 正確

// 這會觸發類型檢查錯誤
request("GET", "/invalidRoute"); // Error: Argument of type '"/invalidRoute"' is not assignable to parameter of type 'Route'.

相同的概念也可以來動態生成 SQL 查詢語句,同時確保參數的安全性與類型正確性。

1
2
3
4
5
6
7
8
9
10
11
type TableName = "users" | "posts" | "comments";

function selectFromTable(table: TableName, id: number): string {
return `SELECT * FROM ${table} WHERE id = ${id}`;
}

const query = selectFromTable("users", 1);
console.log(query); // SELECT * FROM users WHERE id = 1

// 傳入錯誤的表名時,會被 TypeScript 類型檢查發現
selectFromTable("invalidTable", 1); // Error: Argument of type '"invalidTable"' is not assignable to parameter of type 'TableName'.

字串聯合類型構造器

一個常見的模式是將Template Literals 類型與聯合類型結合,這樣可以生成所有可能的組合。
例如,假設我們有一組顏色和相應的色調:

1
2
type ColorShade = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
type Color = "red" | "blue" | "green";

我們可以創建一個顏色調色板,代表所有可能的顏色和色調的組合:

1
2
3
type ColorPalette = `${Color}-${ColorShade}`;
let color: ColorPalette = "red-500"; // 正確
let badColor: ColorPalette = "red"; // 錯誤

這樣,我們就得到了 27 種可能的組合(3 種顏色乘以 9 種色調)。
又或者,在樣式生成工具或 CSS-in-JS 的場景中,Template Literals 可以結合類型系統來強化樣式生成工具的正確性。

1
2
3
4
5
6
7
8
9
10
11
12
type CSSUnit = "px" | "em" | "rem";
type CSSProperty = "margin" | "padding" | "font-size";

function applyStyle(property: CSSProperty, value: number, unit: CSSUnit): string {
return `${property}: ${value}${unit};`;
}

console.log(applyStyle("margin", 10, "px")); // margin: 10px;
console.log(applyStyle("font-size", 1.5, "em")); // font-size: 1.5em;

// 若單位或屬性錯誤,會觸發類型檢查錯誤
applyStyle("background", 10, "px"); // Error: Argument of type '"background"' is not assignable to parameter of type 'CSSProperty'.

另一個例子

在構建大型系統時,常常需要動態生成變量名稱或類型,
這時可以使用 Template Literal Types 來幫助構造更具彈性的類型系統。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Eventa = "click" | "hover" | "focus";
type ElementID = "button" | "input" | "link";

// 動態生成事件處理函數名稱 3x3 種型別檢查
type EventHandlerName<E extends Eventa, T extends ElementID> = `${T}On${Capitalize<E>}Handler`;
const buttonOnClickHandler: EventHandlerName<"click", "button"> = "buttonOnClickHandler";
const buttonOnHoverHandler: EventHandlerName<"hover", "button"> = "buttonOnHoverHandler";
const buttonOnFocusHandler: EventHandlerName<"focus", "button"> = "buttonOnFocusHandler";
const inputOnClickHandler: EventHandlerName<"click", "input"> = "inputOnClickHandler";
const inputOnHoverHandler: EventHandlerName<"hover", "input"> = "inputOnHoverHandler";
const inputOnFocusHandler: EventHandlerName<"focus", "input"> = "inputOnFocusHandler";
const linkOnClickHandler: EventHandlerName<"click", "link"> = "linkOnClickHandler";
const linkOnHoverHandler: EventHandlerName<"hover", "link"> = "linkOnHoverHandler";
const linkOnFocusHandler: EventHandlerName<"focus", "link"> = "linkOnFocusHandler";

const invalidHandler: EventHandlerName<"click", "button"> = "buttonOnHoverHandler"; // Error: Type '"buttonOnHoverHandler"' is not assignable to type '"buttonOnClickHandler"'.

參考網路上的例子

Template Literals 類型允許我們在 TypeScript 中插入其他類型到字符串類型中。
例如,假設我們想要定義一個表示 PNG 文件的類型:

1
type Excel = `${string}.xlsx`;

這樣,當我們為變量指定 Excel 類型時,它必須以 .xlsx 結尾:

1
2
let new_excel: Excel = "my-image.xlsx"; // ✅正確
let old_excel: Excel = "my-image.xls"; // ❌錯誤

當字符串不符合定義時,TypeScript 會顯示錯誤提示,這有助於減少潛在的錯誤。
我們可以確保字符串符合特定的前綴或中間包含特定子字符串。
例如,若要確保路由以 / 開頭,我們可以這樣定義:

1
2
3
type Route = `/${string}`;
const myRoute: Route = "/home"; // ✅正確
const badRoute: Route = "home"; // ❌錯誤

同樣的,如果我們需要確保字符串包含 ?,以便視為查詢字符串,我們可以這樣定義:

1
2
3
type QueryString = `${string}?${string}`;
const myQueryString: QueryString = "search?query=hello"; // 正確
const badQueryString: QueryString = "search"; // 錯誤

此外,TypeScript 還提供了一些內建的實用類型來轉換字符串類型,例如 Uppercase 和 Lowercase,可以將字符串轉換為大寫或小寫:

1
2
type UppercaseHello = Uppercase<"hello">; // "HELLO"
type LowercaseHELLO = Lowercase<"HELLO">; // "hello"

還有 Capitalize 和 Uncapitalize 這兩個實用類型,可以用來改變字符串的首字母大小寫:

1
2
type CapitalizeMatt = Capitalize<"matt">; // "Matt"
type UncapitalizePHD = Uncapitalize<"PHD">; // "pHD"

這些功能展示了 TypeScript 類型系統的靈活性。

結語

Template Literals 類型是一個非常有用的特性,能夠幫助開發者在 TypeScript 中更精確地控制字符串類型。
從定義特定格式的文件名到生成複雜的組合類型,它為代碼提供了更高的可讀性和安全性。
如果你在開發過程中需要處理字符串模式,Template Literals 類型無疑是值得考慮的工具。

參考

(fin)

[學習筆記] TypeScript 字串建議的小技巧

前情提要

最近在寫一個 Hero component,需求是讓使用者能指定英雄的種族。
我們的設計有一些既定的種族,例如 human 和 demon,同時也希望讓使用者能輸入任何自定義的種族名稱。
最初的想法是用以下的定義:

1
type Race = 'human' | 'demon' | string;

並在 Hero 的 props 中使用這個型別:

1
2
3
4
5
6
7
8
9
10
11
export type HeroProps = {
name: string;
race: Race;
}

// component 大概如下
export const Hero = ({ race,name }: HeroProps) => {
return (
<h1>Hero: {name} is {race}</h1>
);
};

這樣一來,使用者可以像這樣使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/components/HeroDisplay.tsx

import React from 'react';
import { Hero } from '../components/hero'; // 引入 Hero 型別

const HeroDisplay = () => {
return (
<div>
<Hero name="alice" race="human" />
<Hero name="mark" race="demon" />
<!--more heros -->
</div>
);
};

export default HeroDisplay;

一切看似沒問題,但問題是——在使用 Hero component 時,TypeScript 並不會自動給出 human 或 demon 這樣的建議。

既然我們希望能提供建議,該怎麼解決這個問題呢?

實作記錄

解決方法看起來有些奇怪,我們可以透過將字串類型與一個空的物件相交來達成目標:

1
type Race = 'human' | 'demon' | (string & {});

這樣一來,在使用 Hero component 時,TypeScript 就會正確地給出 primary 和 secondary 的建議。
為什麼這會起作用?這其實是 TypeScript 編譯器的一個小「怪癖」。
當你把字串常值類型(例如 “human”)與字串類型(string)進行聯集時,
TypeScript 會急切地將其轉換為單純的 string,因此在 Hover 時會看到類似這樣的結果:

1
2
type Race = 'human' | 'demon' | string ;
// Hover 時會顯示: type Race = string

換句話說,TypeScript 在使用前就忘記了 human 和 demon。
而透過與空物件 & {} 進行相交,我們能「欺騙」 TypeScript,讓它在更長時間內保留這些字串常值類型。

1
2
type Race = 'human' | 'demon' | (string & {});
// Hover 時會顯示: type Race = 'human' | 'demon' | (string & {});

這樣,我們在使用 Race 型別時,TypeScript 就能記得 human 和 demon,並給出對應的建議。

值得注意的是,string & {} 實際上和單純的 string 是相同的類型,因此不會影響我們傳入的任何字串:

1
2
<Hero name="alice" race="human" />
<Hero name="mark" race="demon" />

這感覺像是在利用 TypeScript 的漏洞。
不過,TypeScript 團隊其實是知道這個「技巧」的,他們甚至針對這種情況進行測試。
或許將來,TypeScript 會原生支援這樣的功能,但在那之前,這仍是一個實用的小技巧。

範例 Code

小結

總結來說,當你想允許使用者輸入任意字串但又想提供已知字串常值的自動補全建議時,可以考慮使用 string & {} 這個技巧:

它防止 TypeScript 過早將 string | 'literal' 合併成單純的 string。
實際使用時行為與 string 一樣,但會多提供自動補全功能。
這或許不是最正式的解法,但目前仍是一個可以信賴的方式。
也許未來 TypeScript 能夠原生解決這個問題,但在那之前,這個小技巧可以為開發帶來便利。

(fin)

Please enable JavaScript to view the LikeCoin. :P