🎯 什麼情境該想到我
當你「用基本型別(字串、整數、浮點數)去硬撐一個其實有意義的領域概念」的時候——例如用字串存電話號碼、用數字存金額、用一對數字表達範圍。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:偷懶用基本型別,會讓「格式化、驗證、換算、比較」這類本屬於該概念的行為無處安放,只能散落各地重複。把它升級成一個小型別,行為就有了家,語意也更清楚(避免把公里和英里、金額和數量搞混)。
通常用哪些重構手法對治:
- 以物件取代基本型別(Replace Primitive with Object):把「電話號碼」「金額」等做成專屬的小類別。
- 以子類取代型別碼(Replace Type Code with Subclasses):若那個基本值是拿來分類、驅動分支,改用子類。
- 範圍類資料可搭配提煉類別做成 Range 物件。
★ 怎麼具體認出來
| 訊號 | 例子 |
|---|---|
★ 所有 ID 都是 string | func Transfer(from, to string, amount int) —— 傳反了編譯通過 |
金額用 float64 | ★ 0.1 + 0.2 != 0.3;帳務系統的經典災難 |
| ★ 單位藏在變數名裡 | timeoutMs int、sizeKB int —— 改單位時沒有任何保護 |
狀態/型別用 string | status 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 |
★ 判準:這個值會不會「被傳錯、被拿去和不該比較的東西比較、或帶著看不見的不變式」?
會 → 包。不會 → 包裝只是增加噪音和轉換成本。
⚠️ 三個真實的實務代價
- ★ 序列化與 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 進來的資料就完全繞過驗證。 - ★ 零值永遠繞過建構函式。Go 擋不住;要讓零值明顯無效並在使用點檢查。
- ★ 包裝型別會擴散。一旦有了
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 也包起來。
🔗 相關工具
- 重複的 switch Repeated Switches(型別碼常伴隨重複的分支,改用子類或多型)
- 資料泥團 Data Clumps(成群的基本型別常一起升級為物件)
- 過長參數列 Long Parameter List(一串基本型別參數可換成領域物件)
- 重構
- 以物件取代基本型別 —— 主要對治手法
- 以子類取代型別碼 —— 狀態/型別碼用 string 時
- 資料泥團 —— 同一根因的另一面
- 值物件 —— 目標形態
- 工具-實體值物件與聚合 —— DDD 對值物件的定義