🎯 什麼情境該想到我
當你「用一個基本型別(字串、數字…)表示某個其實有自己規則與行為的概念」(例如電話號碼、溫度、金額、顏色)的時候——把它升級成一個小類別。一開始它可能只是包一個值,但很快就會成為安放相關驗證、格式化與換算邏輯的好地方。
⚙️ 怎麼用(步驟 / 公式)
意圖:把「其實有行為與規則的基本型別值」升級成一個類別。
動作要點(小步安全進行):
- 若該資料尚未封裝,先對它做封裝變數 Encapsulate Variable。
- 建立一個簡單的「值類別(value class)」,建構子接收這個基本值,並提供取值方法(先做成不可變)。
- 讓設值函式改為建立這個值類別的實例、取值函式改為回傳裡面的值。
- 逐步把散落各處、針對這個值的相關邏輯(驗證、格式化、比較、換算)搬進這個新類別。
- 更新型別宣告與使用處,測試。
★ before / after(Go)
// before:電話號碼是 string —— 於是驗證、格式化、比較散落各處
type Order struct {
Customer string
Phone string // ★ "+886912345678"? "0912-345-678"? 沒人知道
}
if len(o.Phone) == 10 && strings.HasPrefix(o.Phone, "09") { … } // ★ 這段被複製了六次
// after
type PhoneNumber struct{ raw string } // ★ 小寫欄位 = 建立後不可變
func NewPhoneNumber(s string) (PhoneNumber, error) {
n := strings.NewReplacer("-", "", " ", "").Replace(s)
if !phoneRe.MatchString(n) { return PhoneNumber{}, fmt.Errorf("bad phone %q", s) }
return PhoneNumber{raw: n}, nil // ★ 驗證只有這一個入口
}
func (p PhoneNumber) String() string { return p.raw }
func (p PhoneNumber) AreaCode() string { return p.raw[:2] }
func (p PhoneNumber) IsMobile() bool { return strings.HasPrefix(p.raw, "09") }★ 收益不是「多一個型別」,是「不變式只需要在一個地方守」。
PhoneNumber存在的意思就是「這個值已經驗過了」——之後所有程式碼都不必再問。
★★ Go 的第二個選項:具名型別(零成本)
type UserID string // ★ 底層仍是 string,沒有任何 runtime 成本
type OrderID string
func LoadUser(id UserID) (*User, error)
LoadUser(orderID) // ★ 編譯錯誤 —— 這正是重點這是 Go 相對其他語言最便宜的一招:型別安全全拿,記憶體佈局完全不變。
type UserID string | type PhoneNumber struct{ raw string } | |
|---|---|---|
| 防型別搞混 | ✅ | ✅ |
| 能掛方法 | ✅ | ✅ |
| 強制走驗證 | ❌ UserID("隨便") 合法 | ✅ 外部無法構造非零值 |
| 零值 | "" 合法但無意義 | PhoneNumber{} 同樣要小心 |
| JSON | ✅ 自動當 string | ★ 要自己寫 MarshalJSON/UnmarshalJSON |
★ 判準:這個值有「非法狀態」嗎?
沒有(純粹是不想搞混 ID)→ 具名型別就夠,不要過度工程。
有(email、phone、金額、百分比、URL)→ struct + 建構函式。
⚠️ 四個實務陷阱
① 零值繞過建構函式
var p PhoneNumber // ★ 沒經過 NewPhoneNumber,但型別系統認它Go 沒辦法禁止零值。對策:讓零值明顯無效並在使用點檢查(p.raw == "" → 回錯),
或在 MarshalJSON/DB 寫入前擋一次。不要假裝這個問題不存在。
② JSON / DB 邊界會繞過驗證
// ★ 一定要實作,否則 json.Unmarshal 會直接塞進去,驗證形同虛設
func (p *PhoneNumber) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil { return err }
v, err := NewPhoneNumber(s)
if err != nil { return err }
*p = v; return nil
}
// DB 端對應的是 driver.Valuer / sql.Scanner→ 這是最常被漏掉的一環:驗證做得再漂亮,Unmarshal 一來就全繞過了。
③ 可比較性
type Money struct{ amount int64; currency string }
m1 == m2 // ✅ 全部欄位可比較 → struct 可以用 ==,也能當 map key
type Tags struct{ list []string }
t1 == t2 // ★ 編譯錯誤 —— 含 slice 的 struct 不可比較④ 不要包成 Money 卻用 float64
type Money struct{ Amount float64 } // ★ 0.1 + 0.2 != 0.3,金額永遠用整數分
type Money struct{ Cents int64; Cur string } // ✅TypeScript
TS 沒有 nominal type,主流做法是 branded type:
type UserID = string & { readonly __brand: 'UserID' };
const toUserID = (s: string): UserID => { /* 驗證 */ return s as UserID; };★ 純編譯期,零執行期成本,但也零執行期保護——as UserID 就繞過了。
要真的驗證就得像 Go 一樣走建構函式(或 zod 之類的 schema)。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
若這個值真的只是單純資料、沒有任何規則或相關行為,多包一層類別是過度設計。
-
★ 別對每個 string 都做。判準是「這個值有不變式、或有行為要掛上去」。
type Name string沒帶來任何東西時,就是 誇誇其談通用性。 -
★ 序列化與 DB 邊界必須自己補(
UnmarshalJSON、sql.Scanner),否則驗證是假的。 -
零值永遠繞過建構函式。Go 擋不住,要靠「零值明顯無效 + 使用點檢查」。
-
含 slice/map 的 struct 不可比較,不能用
==、不能當 map key。需要比較就自己寫Equal。 -
金額不要用
float64。整數分或 decimal。 -
包裝型別會擴散:一旦有了
UserID,所有簽章都要跟著改。這是好事,但要當成一次完整的遷移做完,半套的狀態(一半 string 一半 UserID)最難讀。
🔗 相關工具
- 封裝變數 Encapsulate Variable(本手法的起手式)
- 引入參數物件 Introduce Parameter Object(同樣把零散資料聚成有概念的物件)
- 以查詢取代暫時變數 Replace Temp with Query(搬移行為時常搭配使用)
- 重構
- 值物件 —— 這個手法的目標形態
- 封裝記錄 —— 多欄位版
- 基本型別偏執 —— 觸發本手法的壞味道
- 資料泥團 —— 一組基本型別總是一起出現時
- 引入參數物件 —— 多參數版的同一件事
- 工具-實體值物件與聚合 —— DDD 對值物件的定義