はじめに
開発を進める中で、ID, メールアドレス、あるいは "正規化済みの文字列" などを、すべて標準の string や int で表現してしまうことはないでしょうか?
このように、ドメインの重要な概念を標準の基本型だけで済ませてしまう習慣を "プリミティブ型への執着 (Primitive Obsession)" と呼びます。
この記事では、この問題を解決する "Newtypeパターン" の Go 言語における実践方法と、他のプログラミング言語における類似機能・呼称についてまとめます。
なぜプリミティブ型への執着はよくないのか?
例えば、文字列の正規化処理を行う関数を考えてみます。
// 悪い例: 引数がどちらも string
func SaveDictionaryWord(rawWord string, normalizedWord string) {
// ...
}
この設計の最大の問題は、引数の順番を間違えてもコンパイルエラーにならないことです。
SaveDictionaryWord(normalizedWord, rawWord) と記述しても、コンパイラはどちらも同じ string であるため素通ししてしまい、エラーとして検出できません。
実行時に静かに問題が発生し、問題から遡って理由を見つけ出すという非常にコストが高い対応が必要になります。
Go 言語での解決策:Defined type
Go 言語では、既存の型をベースに「全く新しい型」を定義する機能が言語仕様レベルで提供されています。
これを利用して、ドメインの概念に名前を与えます。
// 型を定義する
type RawWord string
type NormalizedWord string
// 良い例:型で引数を守る
func SaveDictionaryWord(raw RawWord, normalized NormalizedWord) {
// ...
}
func main() {
raw := RawWord(" Hello ")
normalized := NormalizedWord("hello")
// OK: 正しい型を渡している
SaveDictionaryWord(raw, normalized)
// NG: 引数を逆にするとコンパイルエラー!
// SaveDictionaryWord(normalized, raw)
// Cannot use 'normalized' (type NormalizedWord) as the type RawWord
}
このように、プリミティブ型をラップして型安全性を担保する手法は一般に Newtype パターンと呼ばれます。
Go においては、メモリ上のオーバーヘッドがゼロ (実行時はただの文字列と同じ扱い) でありながら、コンパイル時には厳密に別の型としてチェックされるため、パフォーマンスを犠牲にすることなく堅牢なシステムを構築できます。
注意:型エイリアスとの違い
Goには = を使った "型エイリアス (Type Alias)" という、字面がそっくりな機能がありますが別のものです。この用途では使用できません。
// 型エイリアス (単なる別名)
type NormalizedWordAlias = string
func DoSomething(word NormalizedWordAlias) {}
func main() {
// コンパイルが通ってしまう (ただの string として扱われるため安全ではない)
DoSomething("ただの文字列")
}
Newtype パターンを実現するには、必ず = を含まない Defined type (type NewType string の形) を使用します。
振る舞い (メソッド) を持たせる
新しい型を定義するメリットは、コンパイラのチェックだけではありません。
その型専用のメソッドを生やすことができるようになります。
type NormalizedWord string
// 正規化済み文字列にのみ許された操作を定義できる
func (nw NormalizedWord) ToUpper() string {
return strings.ToUpper(string(nw))
}
他言語ではどう呼ばれているか?
この "プリミティブ型を安全な型に引き上げる" というアプローチは、Go 言語に限らず様々な言語やパラダイムで重要視されています。
しかし、言語の仕様 (構造的型付けか公称型付けかなど) によって実現アプローチや呼称が異なります。
他の言語のほうが親しみが深いという方向けに、代表的な呼称をまとめました。
1. Newtype (Rust / Haskell)
Go の Defined type と最も近いメンタルモデルを持つのが、Rust や Haskell における Newtype パターンです。
Rust ではタプル構造体を使って struct UserId(String); のように定義し、ゼロコスト抽象化を実現します。
この用語自体が、このパターンの代名詞として言語の垣根を越えて広く使われています。
2. Value class
Kotlin では value class という機能で Go の defined type と同様の機能が提供されています。
@JvmInline
value class UserId(val id: Int)
一方で typealias という機能もあり、こちらは Go の型エイリアスと同様のものです。
// typealias では Go の `=` ありの型エイリアスと同様に、型安全を目的としては使用できません
typealias UserId = Int
3. Branded Types / Branding (TypeScript)
TypeScript は "中身の構造が同じなら同じ型 (構造的型付け)" という仕様を持つため、Go のような型の定義ができません。
そこで、型にダミーのプロパティ (ブランド) を交差型 (Intersection Types) で付与することで、コンパイラに別の型だと認識させるテクニックがよく使われます。
これを Branded Types と呼びます。
4. Strong Typedef (C++)
C や C++ の typedef や using は、Go の型エイリアスと同じく単なる別名であり、型安全ではありません。
そのため、ラップ用のクラスやテンプレートを駆使して厳密に区別される型を作る手法を、通常の typedef と区別して Strong Typedef と呼びます。
5. Tiny Types / Micro Types (Java / C# など)
主にオブジェクト指向言語の界隈で使われる言葉です。
String や Int を生のまま使わず、フィールドを一つだけ持つ小さなクラス (Tiny Type) を大量に作る設計スタイルを指します。
DDD (ドメイン駆動設計) における 値オブジェクト (Value Object) と目的を同じくするアプローチです。
6. Opaque Types (Swift / C など)
"中身の実態 (例えば string であること) を外部から隠蔽する" というカプセル化の側面に焦点を当てた呼び方です。
モジュール境界で型を安全に受け渡す際によく登場する概念です。
おわりに
"とりあえず string", "とりあえず int" で実装を進めると、システムが複雑になるにつれて「この文字列は正規化済みだっけ?」
「この ID はユーザーID? それともグループID?」という混乱が生じます。
Go 言語の Defined type は、驚くほど手軽に、かつパフォーマンスの劣化なしにこの問題を解決してくれます。
今日からぜひ "Newtype" を作って、コンパイラを最高の味方にしましょう。