世界を動かす技術を、日本語で。

型システムを活用する

2025年7月24日原文(dzombak.com)

概要

  • 異なる意味 を持つ値に 型を分ける 重要性を解説
  • intやstring など汎用型の乱用による バグ例 を紹介
  • Go言語libwxライブラリ での具体例
  • 型ごとの 変換メソッド型安全性 の利点を説明
  • GitHub でサンプルコードを公開

単純な値に独自型を定義するテクニック

  • 多くのプログラムで、 intやstring、UUID などのシンプルな型をそのまま利用するケースが多い
  • しかし、これらの型を 異なる意味 で使い回すと、 IDの取り違え引数の順序ミス などのバグが発生しやすい
  • 例えば、 UserIDAccountID がどちらもstringやUUID型の場合、 意図しない代入や関数呼び出し がコンパイル時に検出できない
  • 解決策として、 用途ごとに新しい型 を定義し、 意味づけ を明確にすることが有効
  • 型を分けることで、 コンパイラによる型チェック が働き、 人為的ミス を未然に防止

Go言語での例

  • 例えば、Goでは次のようにIDごとに型を定義可能

    type AccountID uuid.UUID
    type UserID uuid.UUID
    
  • このように型を分けると、 UserIDを受け取る関数にAccountIDを渡すとコンパイルエラー となる

  • 逆に、 型を分けずにuuid.UUID型のまま使うと、引数の取り違えがコンパイル時に検出できず、ランタイムでバグ発生

libwxライブラリでの型安全性

  • Go用の libwxライブラリ では、 気象や大気の計算 に使う値ごとに型を定義
  • 例: 温度はTempF型やTempC型、湿度はRelHumidity型 など
  • 型ごとに 変換メソッド(例:Km.Miles()) も実装
  • これにより、 float64だけで値を扱う場合に起きやすい単位や意味の取り違え を防止
  • 関数の引数に 間違った型 を渡すと、 コンパイルエラー で即座に検出

コード例

```go
temp := libwx.TempF(84)         // 華氏温度
humidity := libwx.RelHumidity(67) // 相対湿度(%)

// 誤って摂氏温度が必要な関数に華氏温度を渡すとエラー
fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointC(temp, humidity))
// => エラー: 'temp' (libwx.TempF型)をTempC型として使えない

// 引数の順序を間違えてもエラー
fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointF(humidity, temp))
// => エラー: 引数の型が一致しない
```

型システムの活用のすすめ

  • 型システムはバグ防止の強力な味方
  • モデルごとにID型を分ける、関数の引数に floatやintだけを使わない 設計が推奨
  • intやstring、UUID を使い回すことで、 実際のシステムでも多くのバグが発生
  • Goのような 比較的シンプルな型システム でも、 型定義による安全性向上 は十分に実現可能
  • このテクニックは簡単なのに広く使われていない ため、ぜひ積極的に導入を推奨

サンプルコードとリソース

  • 本記事で紹介した コード例や詳細 はGitHubで公開
  • cdzombak/libwx_types_lab リポジトリにて参照・貢献が可能
  • URL: https://github.com/cdzombak/libwx_types_lab

Hackerたちの意見

これ、いいね。まさに「悪い状態を表現できなくする」って感じだ。だけど、このアプローチには問題もあって、開発者が最初のタイプ実装の段階で止まっちゃうと、すべてがタイプになって、うまく組み合わさってないことが多い。タイプが微妙に変わっただけのものがたくさんあって、理由を考えるのが難しくなる。そんなシステムなら、むしろJSみたいな弱い型の動的言語か、Elixirみたいな強い型の動的言語を書いてる方がいいかな。でも、開発者が論理を型制御のフローに押し込んでいくと、例えば条件付きロジックをパターンマッチのあるユニオンタイプに移動させたり、デリゲーションを活用したりすると、また楽しくなるよね。例えば「DewPoint」関数は、どちらのタイプでも受け入れてそのまま動くようにできるかも。

ちなみに、Rubyは強い型付けだよ、緩いわけじゃない。 > 1 + "1" (irb):1:in 'Integer#+': String can't be coerced into Integer (TypeError) from (irb):1:in '' from :168:in 'Kernel#loop' from /Users/george/.rvm/rubies/ruby-3.4.2/lib/ruby/gems/3.4.0/gems/irb-1.14.3/exe/irb:9:in '' from /Users/george/.rvm/rubies/ruby-3.4.2/bin/irb:25:in 'Kernel#load' from /Users/george/.rvm/rubies/ruby-3.4.2/bin/irb:25:in ''

そうだね。この理由から、もっと多くの言語がバウンド整数をサポートしてほしいな。例えば、x: u32って言う代わりに、型システムを使ってxを[0, 10)の範囲に制約できるようにしたい。これによって、いくつかの良い特性が得られるし、今の言語ではできない小さな最適化も可能になる。例えば、配列の範囲内に収まる整数を作れるようになる。そうすれば、配列にインデックスを付けるときに範囲チェックをする必要がなくなる。Optionを使った時にも、もっと多くのピープホール最適化ができるようになる。奇妙なことに、RustはLLVMの魔法のおかげで、関数内ではこれをある程度サポートしてるんだけど、関数間で渡される変数にはサポートしてないんだよね。

最近、赤-緑-リファクタリングをやってるんだけど、失敗したテストの代わりに型システムを厳しくして、プロダクションで報告されたバグが型チェッカーを失敗させるようにしてから、そのバグを直して緑にするようにしてる。新しい機能やエッジケース、型システムを変えても失敗を引き起こせないバグには、TDD-with-a-testを続けてる。でも、型システムを使った赤-緑-リファクタリングは通常すぐに終わるし、バグのクラス全体に対して強い保証を提供できるんだ。

「悪い状態を実験できないようにする」とも言われてるね。

「型実装の最初のレベルで止まる」ってところで、コードベースが失敗するのをよく見るよ。「このintを構造体でラップしてUUIDと呼ぶ」っていう例は、すごく良いスタートだし、だいたいそこから始めるんだけど、必ず誰かがその安全性を回避しちゃうんだ。UUIDを受け取る関数があって、intを持っていると、無思考で自分のintをUUIDでラップして進んじゃう。そうすると、そのUUIDが実際にユニークでない可能性があるから、そういう前提に依存しているコードが壊れちゃう。ここで「構造によって正しい」という概念が重要になってくる。もしあなたのコードにUUIDが実際にユニークであるという前提条件があるなら、それがユニークでないものを作るのはできるだけ難しくすべきだよ。コンストラクタが例外を投げたり、初期化がErrを返したり、あなたの好きな言語のイディオムで、誰かがその不変条件が証明されていないUUIDを取得できる唯一の方法は、本当に本当に自分が何をしているかを知っている時だけだよ。(UUIDをサブして、ユニーク性の不変条件を他の型や不変条件に当てはめても、同じことが言える)

ユニオンタイプ!!すべてが型で、何も一緒に動かないなら、インターフェースでラップして、すべてを一度にユニオンする超型を定義しよう。TypeScriptへようこそ。ここでは、ジェネリクスが私たちのジェネリックなジェネリクスの中心にあって、ボブが8年前に書いた何かのジェネリックなジェネリックを投げてる。彼らが作ったアーキテクチャで論理的に考えられないから、型システムに投げて整合性を保ってるんだ。大体はうまくいくけど、Rustは間違ってるって教えてくれるのが美しい。結局、私たちが増え続ける複雑さの中で柔軟性を設計できてないだけなんだよね。「コンポーネント」が「コントロール」だった頃、あれが十数個しかなかったのを覚えてる?NNが数十万のパラメータしかなかった頃を覚えてる?計算能力が増すにつれて、私たちのメンタルモデルもそれに合わせて理解を深めていかないといけない。でも、そのメンタルモデルをしっかり持っておかないと。もしタイプするなら、やるべきだし。厳密なテストが必要なら、テストを書こう。シミュレーションが必要なら、友達、実行しよう。結局、私たち全員が望んでるのは、予期しない方法で壊れない高品質なソフトウェアなんだ。

これは通常、名義型ではなく構造型で緩和できるよ。本当に必要なら名義型を強制することもできるけどね。

一般的な考えには同意せざるを得ないけど、すべてがユニークなタイプだったシステムでのひどい体験を無視するのも難しい。基本的に、バイトを移動させたいコードと、特定のドメインでの計算をしたいコードが混ざると、うまくいかないことが多いと思う。今のところ、これをうまく言い換える方法は思いつかないな。 :(

なんとなく言いたいことがわかるかも。データは手元にあるのに、タイプを作成したりインスタンス化したりする方法を探さなきゃいけない状況ってあるよね。クックブックやチートシートがないと、ドキュメントの中で宝探ししてる気分になる。例えば、{ x, y, z }があるのに、createVector(x, y, z): Vectorを使わなきゃいけない場合とか。で、vertices: Vector[]を使ってcreateFace(vertices): Faceを作ることができるけど、Faceはただの{ vertices }なんだよね。これも、Faceに法線を反転させるメソッドがあるからなんだ。もう一つの例は、JavaのBouncyCastleみたいなライブラリで、必要なバイト配列はあるのに、8種類の異なるタイプをインスタンス化して、それぞれのメソッドを使わないと、hash(data, "sha256")ができないっていう。

理想的には、コンパイラがドメイン固有のロジックをシンプルなバイト移動に変換して、タイプが合ってるか確認した後に処理する感じかな。でも、もしかして誤解してた?

型システムは、他のツールと同じように80/20の法則があるよね。型をやりすぎると、ライブラリを使うのがすごく面倒になって、ほとんどメリットがないか、逆にマイナスになっちゃうことが簡単にある。UUID(またはString)が何かは知ってるけど、AccountIDやUserIDが何かは知らない。だから、あなたのソフトウェアを使うためには、それらが何なのか(どうやって作るのかも)を知る必要がある。 elaborateな型システムが価値あるかもしれないけど、そうじゃないかもしれない(特に良いテストがあるなら)。

Hacker Newsで議論の続きを見る