🎯 什麼情境該想到我

當你「用基本型別(字串、整數、浮點數)去硬撐一個其實有意義的領域概念」的時候——例如用字串存電話號碼、用數字存金額、用一對數字表達範圍。

⚙️ 怎麼用(步驟 / 公式)

這味道是什麼徵兆:偷懶用基本型別,會讓「格式化、驗證、換算、比較」這類本屬於該概念的行為無處安放,只能散落各地重複。把它升級成一個小型別,行為就有了家,語意也更清楚(避免把公里和英里、金額和數量搞混)。

通常用哪些重構手法對治:

  • 以物件取代基本型別(Replace Primitive with Object):把「電話號碼」「金額」等做成專屬的小類別。
  • 以子類取代型別碼(Replace Type Code with Subclasses):若那個基本值是拿來分類、驅動分支,改用子類。
  • 範圍類資料可搭配提煉類別做成 Range 物件。

★ 怎麼具體認出來

訊號例子
★ 所有 ID 都是 stringfunc Transfer(from, to string, amount int) —— 傳反了編譯通過
金額用 float64★ 0.1 + 0.2 != 0.3;帳務系統的經典災難
★ 單位藏在變數名裡timeoutMs int、sizeKB int —— 改單位時沒有任何保護
狀態/型別用 stringstatus string —— "active"?"ACTIVE"?"Active"?
電話/郵遞區號用數字開頭的 0 會消失
驗證邏輯被複製if !strings.Contains(email, "@") 出現在五個地方
# ★ 找出「所有參數都是 string」的函式 —— 高風險簽章
grep -nE 'func [A-Za-z]+\([a-z]+, [a-z]+ string' --include='*.go' -r .
# golangci-lint 的 mnd(magic number detector)可以抓出裸的數字字面量(2026-09-24 查證)

★★ Go 的兩層解法(成本差很多)

// ★ 第一層:具名型別 —— 零執行期成本,零序列化成本
type UserID string
type OrderID string
func LoadUser(id UserID) (*User, error)
LoadUser(orderID)         // ★ 編譯錯誤 —— 這就是全部的收益,而且是免費的
 
// ★ 第二層:struct + 建構函式 —— 能強制驗證,但要處理序列化
type Email struct{ raw string }
func NewEmail(s string) (Email, error) { … }
func (e Email) String() string { return e.raw }
具名型別struct + 建構函式
防型別搞混✅✅
掛方法✅✅
強制驗證❌ UserID("亂打") 合法✅
JSON/DB✅ 自動當底層型別★ 要自己寫 MarshalJSON/Scanner/Valuer
零值""Email{} —— 兩者都繞過驗證

★ 判準:這個值有「非法狀態」嗎?
沒有(只是不想搞混 ID)→ 具名型別就夠,不要過度工程。
有(email、金額、百分比、URL、電話)→ struct + 建構函式。

★ 三個 Go 的標準做法

// ① ★ 狀態/枚舉:具名 int + iota + Stringer(不要用 string)
type Status int
const (StatusPending Status = iota; StatusActive; StatusClosed)
//go:generate stringer -type=Status
// ★ 好處:編譯期擋住打錯的值;壞處:Go 沒有窮盡檢查(見 [[以子類取代型別碼]])
 
// ② ★ 時間長度:用 time.Duration,不要用 int + 單位後綴
func Dial(timeout time.Duration)      // ✅ Dial(5*time.Second) 讀得懂、改單位不出錯
func Dial(timeoutMs int)              // ★ Dial(5000) —— 毫秒?秒?
 
// ③ ★★ 金額:整數的最小單位 + 幣別,絕不用 float64
type Money struct {
    Cents    int64
    Currency string
}
// float64 的問題不只是精度:0.1+0.2 的誤差會在對帳時累積成真實的差額

★★ 什麼時候這其實不是味道

情況說明
★ 真的就是一個數字/字串func Repeat(s string, n int) 的 n 不需要 type Count int
★ 一次性的、不跨越邊界的值函式內的區域變數
標準庫的既有介面http.Header 是 map[string][]string,包起來只會製造轉換成本
★ wire format 的邊界JSON/protobuf 那一層本來就是基本型別;包裝發生在邊界之後
只有一個用途且不會搞混func Sqrt(x float64) float64

★ 判準:這個值會不會「被傳錯、被拿去和不該比較的東西比較、或帶著看不見的不變式」?
會 → 包。不會 → 包裝只是增加噪音和轉換成本。

⚠️ 三個真實的實務代價

  1. ★ 序列化與 DB 邊界必須自己補,否則驗證形同虛設:
    func (e *Email) UnmarshalJSON(b []byte) error { /* 走 NewEmail */ }
    func (e Email) Value() (driver.Value, error)  { return e.raw, nil }
    func (e *Email) Scan(src any) error           { /* 走 NewEmail */ }
    漏了 UnmarshalJSON,API 進來的資料就完全繞過驗證。
  2. ★ 零值永遠繞過建構函式。Go 擋不住;要讓零值明顯無效並在使用點檢查。
  3. ★ 包裝型別會擴散。一旦有了 UserID,所有簽章都要跟著改——
    這是好事,但要當成一次完整遷移做完。一半 string、一半 UserID 是最糟的狀態。

TypeScript

// ★ TS 沒有 nominal type,只能用 branded type —— 純編譯期,零執行期保護
type UserID = string & { readonly __brand: 'UserID' };
const toUserID = (s: string): UserID => { if (!/^u_/.test(s)) throw new Error(); return s as UserID; };

★ as UserID 就繞過了。要真的驗證,得像 Go 一樣走建構函式(或 zod 之類的 schema)。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 不是每個字串/數字都要包成物件;只有當它承載領域意義、且有相關行為或約束時才值得。

  • 純粹的、無附帶行為的量值在小範圍內用基本型別更輕便。

  • ★ 別對每個 string/int 都包。判準是「會不會被傳錯、被錯誤比較、或帶著看不見的不變式」。

  • ★ 序列化與 DB 邊界必須自己實作(UnmarshalJSON、sql.Scanner、driver.Valuer),否則驗證是假的。

  • ★ 零值永遠繞過建構函式,Go 擋不住。要讓零值明顯無效。

  • ★★ 金額不要用 float64,用整數的最小單位 + 幣別。

  • 含 slice/map 的 struct 不可比較,不能用 ==、不能當 map key。

  • ★ 包裝型別的遷移要一次做完。半新半舊最難讀。

  • wire format 那一層本來就是基本型別。包裝發生在邊界之後,不是把 JSON struct 也包起來。

🔗 相關工具