🎯 什麼情境該想到我

當你「用一個基本型別(字串、數字…)表示某個其實有自己規則與行為的概念」(例如電話號碼、溫度、金額、顏色)的時候——把它升級成一個小類別。一開始它可能只是包一個值,但很快就會成為安放相關驗證、格式化與換算邏輯的好地方。

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

意圖:把「其實有行為與規則的基本型別值」升級成一個類別。

動作要點(小步安全進行):

  1. 若該資料尚未封裝,先對它做封裝變數 Encapsulate Variable。
  2. 建立一個簡單的「值類別(value class)」,建構子接收這個基本值,並提供取值方法(先做成不可變)。
  3. 讓設值函式改為建立這個值類別的實例、取值函式改為回傳裡面的值。
  4. 逐步把散落各處、針對這個值的相關邏輯(驗證、格式化、比較、換算)搬進這個新類別。
  5. 更新型別宣告與使用處,測試。

★ 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 stringtype 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)最難讀。

🔗 相關工具