支援者番号が、寄付額の欄に座っていた
土曜の午後だった。いつも使っているコワーキングスペースは、週末で人がまばらで、僕のキーボードの音だけが響いていた。コーヒーは、とっくに冷めていた。
僕は、小さな寄付プラットフォームを、ひとりで作って、ひとりで運用している。NPOが継続寄付——支援者からの毎月の寄付——を受け付けるための仕組みだ。設計も、コードも、サーバーの面倒も、ぜんぶ僕ひとり。レビューしてくれる人は、いない。
その日、運営してもらっているNPOから、一通の問い合わせが転送されてきた。
「支援者さまから連絡です。『受領証明書の寄付額が ¥80,423 になっている。私が毎月続けているのは ¥5,000 です』とのことで……」
80,423。その数字を見て、背筋が冷たくなった。打ち間違いなんかじゃない。それは、ちゃんと意味のある数字だった。その支援者の、支援者ID。寄付額の欄に、IDが座っていた。
受領証明書は、確定申告で寄付金控除を受けるための、正式な書類だ。桁が違えば、支援者を税務の話にまで巻き込んでしまう。あわててDBを見ると、同じ「寄付額=支援者ID」の確定済み証明書が、先月分に何件もあった。
エラーログは、一件も無い。panic も出ていない。確定処理は、毎月、ちゃんと「成功」していた。ただ、金額が、静かに、IDに化けていた。
原因は、すぐに分かった。先月、Donation まわりを整理したとき、受領証明書を作る FinalizeMonthly の呼び出しで、寄付額を渡すつもりが、コピペした d.SupporterID を直し忘れていた。d.Amount に直すべき場所を。
もう直した。たった1行、d.SupporterID を d.Amount に書き換えるだけ。これで、今日の証明書は正しくなる。でも——書き換えながら、指先が冷たくなった。直したのは、挿し間違えた1本だけだ。挿さる口は、int と int のまま、何も変わっていない。明日の僕が、また同じ口に、同じ手で、IDを挿せる。
自分で書いて、自分でテストして、それでも、気づけなかった。型が同じだと、こうなるのか。int に int を渡して、何が悪い、とコンパイラは言う。じゃあ僕は、これから一生、引数を指で押さえて確かめるのか。
チームはない。レビューしてくれる人も、いない。ただ、月に一度行く個人開発者のもくもく会で、一度だけ隣り合った人が、「コードに詰まったら、この人に頼るといい」と、連絡先だけ残していった。藁にもすがる気持ちで、連絡した。
連絡から一時間ほどで、その人は来た。手ぶらに見えたが、薄いノートPCを一台だけ提げている。名乗りも前置きもなく、「席、空いてますか」と僕の隣を指した。腰を下ろすと、もう画面に目を落としている。僕は、いつのまにかこの人を、親方と呼んでいた。
「取り違えなんです」と僕は切り出した。「寄付額を渡す引数に、支援者IDを渡していました。コピペの、直し忘れで。もう直したんですけど——コンパイルも、テストも、何も言わなかった。自分で書いて、自分でテストして、それでも気づけなかったんです」
親方は、症状ではなく、別のことを聞いた。「その引数、両方とも int ですか」
「……はい。支援者IDも、寄付額も、int です」
「その関数を、呼んでいる側ごと、見せてください」
開いた画面には、引き継ぎでも何でもない、自分で書いたコードがあった。
| |
そして、事故った呼び出し——直す前の姿——を見せた。
| |
「なんで、コンパイルが通るんですか」と僕は聞いた。「寄付額の引数に、IDを渡してるのに」
「amount が int だからです」と親方は言った。「支援者IDも int。コンパイラには、同じ int にしか見えない。意味が違うのに、型が同じ。だから、見分けられない」
意味が違うのに、型が同じ。その言葉が、妙に引っかかった。
直したのは一本。口の形は、そのままだ
親方は何も言わず、僕のキーボードを借りた。事故った呼び出しを、短く書き直す。Receipt を、そのまま表示する。
| |
走らせた。出力は、これだった。
| |
「エラーは nil」と親方は言った。「処理は、成功しています。なのに、寄付額は 80423——支援者IDそのものだ」
ぞっとした。「ほんとだ……。成功してる。¥80,423 の証明書が、ちゃんと『正常に』作られた。これが、支援者さんに届いたんだ」
抽象的だった「事故」が、目の前で、<nil> 80423 という一行になっていた。
親方は、僕がもう直した1行を確認した。
| |
「直りました」と僕は言った。半分は安堵で、半分は——諦めだった。「¥5,000 になった。でも、これ、また誰かが——僕が——やりますよね。int に int を渡すだけだから」
「ここは塞がりました」と親方は言った。「今日の証明書は、もう正しい。でも——口の形は、変えていません」
親方は、FinalizeMonthly のシグネチャを、指でなぞった。「supporterID int、amount int。意味の違う二つが、同じ int の口で並んでいる。あなたが直したのは、挿し間違えた一本だけだ。口の形がこのままなら、同じ部品が、また挿さる。今日のは、ミスじゃない。この口が、いつか必ず起こすことを、たまたま今日、引いただけです」
痛いところだった。それでも、僕はまだ食い下がった。「でも、金額もIDも、ただの数字じゃないですか。int で持つのが、普通でしょう」
「数字に見えるのは、人間がそう読むからです」と親方は言った。「コンパイラには、ただの int だ。『これは寄付額』『これは支援者ID』という区別を、型のどこにも書いていない。書いていないものは、守れない」
同じ形の口には、何でも挿さる
親方は、FinalizeMonthly の三つの引数を、順に指した。「supporterID、amount、email。この関数の口は、三つ。でも、形は二種類しかない。int の口が二つと、string の口が一つ」
「int の口には、int なら何でも挿さる」と親方は続けた。「支援者IDでも、寄付額でも、注文番号でも、年齢でも。口の形が同じだから、中身が何かは、問われない。——燃料ラインの継手と、冷却水のラインが、同じ形をしていたら。整備士は、いつか、燃料の口に冷却水を繋ぐ。気づかないまま」
口の形。僕は、その言葉を、頭の中で転がした。
「これは Primitive Obsession(プリミティブ・オブセッション)です」と親方は言った。プリミティブ・オブセッション——金額や支援者IDのような「意味のある概念」を、専用の型ではなく、裸の int や string(プリミティブ型)のまま持ってしまう癖のことだ。
「int も string も、言語が最初から持っている、いちばん素の型だ」と親方は言った。「素のままだと、速い。考えなくていい。だから、つい、何でもそれで済ませる。済ませた瞬間は、ラクだ。ツケは、あとで来る」
言われて、自分のコードを見返した。支援者IDも、寄付額も、int。メールアドレスは string。寄付の参照番号も、たしか int で持っていた。ぜんぶ、素の型だった。早く動かしたくて、何も考えずに int と string を並べてきた。意味は、僕の頭の中にしかなかった。コードのどこにも、「これは寄付額だ」とは、書いていない。
親方は、メールアドレスに目を移した。「メールアドレスも、同じです。string の口には、どんな文字列でも挿さる。yamada@example.com も、a@b も、空っぽの文字列も。だから——」と、コードの別の場所を開いた。
僕のコードには、メールアドレスを確かめる処理が、三箇所に散っていた。
| |
「同じ『メールアドレスが正しいか』の確認が、三箇所に、別々のやり方で書いてある」と親方は言った。「Register は Contains で @ を見るだけ。SendThanks は正規表現。だから、a@b のような文字列は、登録は通るのに、サンクスメールでは弾かれる。同じ値が、場所によって、正しかったり、正しくなかったりする」
「……コピペしたんです」と僕は白状した。「登録のときに書いて、確定のときにまたコピーして、サンクスのときは正規表現の方がいいかと思って、書き直して。少しずつ、違ってる」
そこで、思い当たることがあった。
「……そういえば、先々月。別の支援者から『受領証明書が届かない』と問い合わせが、ありました。登録は、できてる。でも、メールが戻ってきてた。アドレスが yamada@example——ドットから先が、抜けてたんです。Register の @ チェックは、それを通した。システムには、ちゃんと入っていた。なのに、送ると弾かれる。誰も気づかないまま、あの人にも、書類が届いていなかった」
口にして、はじめて、その二つが繋がった。寄付額の取り違えも、届かなかった証明書も、根は同じだった。裸の値に、意味が乗っていない。int も string も、ただの素の型のまま、何の意味も持たずに、そこにあった。
「同じ口径のソケットが並んでいれば、そりゃあ間違えて差し込むさ」親方は、僕の頭上にある配管を指さすように言った。「燃料の給油口と、ウォッシャー液の注入口がまったく同じ形状で、隣り合わせに並んでいるようなものだ。人間がどれだけ気をつけたって、いつか必ず差し間違える」
同じ型の口だから、何でも挿さる。僕のコードは、そんな危ういソケットだらけの配管だったのだ。おまけに、流れる液体の安全チェックは、下流のいたるところでバラバラに行われていた。

裸の数値に、意味の型を着せる
「裸の値に、意味の型を着せます」と親方は言った。「寄付額は寄付額の型、支援者IDは支援者IDの型。口の形を、概念ごとに分ける」
まず、二つの型を作った。
| |
「type SupporterID int64……」と僕は読んだ。「これ、ただ int64 に別名を付けただけに見えます。中身は同じ int64 なのに、意味、あるんですか」
「別名じゃない。別の型です」と親方は言った。「SupporterID と Money は、中身がどちらも int64 でも、Goにとっては別物。Money を入れる口に、SupporterID は挿さらない」
type SupporterID int64 は、int64 を土台にした、新しい型を作る書き方だ。SupporterID という名前が付いた瞬間、それは「ただの int64」ではなく「支援者IDという意味を持った型」になる。土台が同じでも、Goは、名前の違う型を、別物として扱う。僕は、頭の中でそう言い直した。
親方は、関数の口も、構造体のフィールドも、その型に変えた。
| |
そして親方は、今日の取り違えを、もう一度、わざと書いた。d.SupporterID を、amount の口に。
| |
ビルドした。止まった。
| |
「さっきは、これが通った」と親方は言った。引っぱりはしなかった。「今は、通らない。Money の口に、SupporterID は挿さらないからです。今日の事故は、もう、コンパイルの時点で止まる」
「口の形を、分けたから」と僕は言った。「違う部品は、物理的に、挿さらない。型が、見張ってくれてる」
「ただし」と親方は付け加えた。「数字を直に書いた FinalizeMonthly(1, 1000, email) は、通ります」
「え」
「1000 は、まだ型のついていない、ただの数だからです。Money にも、SupporterID にも収まる。型が止めるのは、別の型の『変数』を取り違えて渡す——今日のような——事故であって、最初から 1000 と打ち込む打ち間違いまでは、見ない。型は、万能じゃない。今日の事故を、構造で止める。それだけです」
正直だな、と思った。直してくれた本人が、その限界まで、先に言う。
散らばった検証が、ひとつの口に集まる
「メールアドレスは、もう一段やります」と親方は言った。「正しいものしか作れない型にする」
| |
「NewEmail は、Email と error を、返してます」と僕は読んだ。「正しければ Email、ダメなら error。(Email, error) って、そういう意味か」
「そうです」と親方は言った。
Email の中の v は、小文字で始まっている。Goでは、小文字始まりの名前は、そのパッケージの外からは触れない。だから、別の場所から Email{v: "でたらめ"} と、直接は作れない。Email を手に入れる道は、NewEmail ただ一つ。そして NewEmail は、検証を通った文字列しか、Email にしない。
最後の func (e Email) String() string の、関数名の前にある (e Email) は、レシーバと呼ばれる。この動作を Email という型にぶら下げる書き方で、e が、その Email 自身を指す。String() は、fmt.Println などで文字列にするときに、Goが自動で呼ぶ決まった名前だ。中の v は外から触れないから、この読み取り専用の口だけが、値を取り出す出口になる。
親方は、Register、FinalizeMonthly、SendThanks を、順に開いた。そして、それぞれのメール検証を、消していった。
| |
「三箇所にあった検証が、消えました」と親方は言った。「NewEmail の、一箇所だけになった。Register も、SendThanks も、もう確かめません。受け取った時点で Email なら、それは正しい。一度 Email になれば、その先は、誰も、検証し直さなくていい」
「さっきの yamada@example」と親方は続けた。「あれは、NewEmail で止まります。入口で。登録のときに弾けば、半端なアドレスは、そもそもシステムに入らない。三箇所のどれかをすり抜ける、ということが、起きなくなる」
画面を見た。三回コピペした、あのちぐはぐな確認が、ぜんぶ、消えていた。string だったときは、メールアドレスを受け取るたびに、「これ、確かめたっけ」と、どこかで不安だった。Email になったら、もう、信じていい。
——先々月の、あの人も。これなら、登録の時点で気づけた。書類が、ちゃんと届いた。
胸の奥が、少し、軽くなった。直したかったのは、たぶん、今日の一件だけじゃなかった。
親方がやったこれには、名前があった。Value Object(バリュー・オブジェクト)——裸の値(int や string)を、意味を持った専用の型でくるみ、不正な値では作れないようにしたものだ。Money のように、型を分けるだけの軽いものから、Email のように、生成のときに検証するものまで、含む。
ひとつ、訊いてみた。「引数に名前を付ける、みたいなやり方でも、取り違えは防げません?」
「呼び出しの一箇所なら、それでもいい」と親方は言った。「でも、型にすれば——構造体のフィールドでも、別の関数の引数でも、どこに置いても、取り違えが挿さらない。意味そのものを、型に持たせるからです」
口の形を分けるというのは、関数の入口だけの話じゃなかった。値そのものに、意味を持たせる。だから、その値が行く先のどこでも、形が合わなければ挿さらない。
「専用の型を作るということは、ソケットの形状そのものを変えることだ」親方は言った。「四角い口には四角いプラグしか挿さらないし、六角形の口には六角形のプラグしか挿さらない。そして、一度安全なフィルタを通した液体だけを、その専用ラインに流す」
型という専用の規格を持った継手が、互いのパイプを正確に繋ぎ、異物の侵入を入口で阻む。それはまるで、プログラムの中に組み込まれた、自動の物理的な安全装置のようだった。

試運転——ひとりでも、型が見張ってくれる
直したコードに、テストを書いた。といっても、古いコードを消したわけじゃない。事故当時の、裸の int のままの版は before/ という別のパッケージに、型を着せた新しい版は after/ に置いて、両方を残してある。まず、Before——before/ に残した、事故をそのまま記録するテストだ。ここは昔のまま int なので、さっき After で見た「取り違えはコンパイルで止まる」とは関係なく、取り違えは、通って、走る。
| |
このテストは、バグが「直った」ことではなく、バグが「起きる」ことを、確かめている。err は nil、Amount は 80423。先月、本番で起きていたことが、そのままテストになった。これが、二度と同じ事故を起こさないための、記録になる。メール検証の食い違い——yamada@example が、Register は通り、SendThanks は弾く——も、同じように記録した。
After は、逆向きのことを確かめる。
| |
テストの中の SupporterID(80423) や Money(5000) は、ただの数に、その型を着せる書き方だ。さっき親方が「リテラルの 1000 は、まだ型がついていない」と言っていた、あの素の数に、ここでは「これは支援者IDだ」「これは金額だ」と、はっきり型を着せている。
取り違えが「コンパイルを通らない」ことは、テストには書けない。書いた瞬間、ビルドが落ちるからだ。だから、それは、さっき親方が見せてくれた、あのコンパイルエラーで確かめた。テストに残せるのは、通るコードだけ。通らないことの証明は、コンパイラがやってくれる。
走らせた。
| |
ひとつ、確かめておきたいことがあった。あの応急処置——d.SupporterID を d.Amount に直した、あの一行——も、今日の取り違えは、ちゃんと防ぐ。型にした After も、防ぐ。「今日の事故は起きない」という結果だけ見れば、二つは同じだ。
違うのは、次の取り違えを、どう防ぐかだった。応急のほうは、僕が、その一行を、正しく書けているあいだだけ、正しい。次に同じ口へIDを挿せば、また通る。型にしたほうは、口の形そのものが違うから、挿そうとした瞬間に、コンパイラが止める。直したのは、同じ一件。変えたのは、次の一件が、黙って通れるかどうか、だった。
「これで、寄付額に支援者IDは挿さりません」と親方は言った。「メールは、Email になった時点で正しい。あなたが指で押さえて確かめていたことを、これからは、型がやります」
ひとりで書いてきた。レビューしてくれる人は、いない。だから、間違えたら、誰も止めてくれないと思っていた。——違った。型に意味を持たせれば、型が、僕の代わりに、見張ってくれる。ひとりじゃ、なかった。
親方は、Email の定義を最後にもう一度だけ確かめると、ノートPCを閉じた。それを小脇に抱えて、静かに席を立つ。引き止める間も、なかった。
「整備士は、いません」去り際に、親方はそう言った。「だが、新しい値が出てくるたびに、int や string のまま放っておかないこと。意味のあるものには、型を着せる。そうすれば、型が、あなたのレビュアーになる。手が滑った瞬間に、コンパイラが、声を上げてくれます」
返事は、すぐには出てこなかった。代わりに、思った。気をつけます、と言うのは簡単だ。でも、気をつけるのは僕で、僕は、また忘れる。忘れても止まるように、型に、見ていてもらう。それが、ひとりで書く僕の、たぶん一番の備えだ。
最初の問い合わせメールを、もう一度開いた。80,423。あの数字は、もう二度と、寄付額の欄には座れない。座ろうとした瞬間、コンパイラが、止める。
支援者さんに、正しい受領証明書を、送り直そう。今度は、桁を、指で押さえなくていい。
整備記録簿
| こんな異音・症状が出たら | 入れるべき整備(Value Object) | まだ様子見でいい |
|---|---|---|
意味の違う値(支援者ID・寄付額・メール等)を、ぜんぶ裸の int/string で持ち、引数で取り違えてもコンパイルが通る | ✓ | |
| 同じ値の検証(メール形式・金額の正負 等)が、複数箇所にコピペで散り、少しずつ食い違っている | ✓ | |
「この string は検証済みか」が型から読めず、受け取るたびに確かめ直しているか、確かめ忘れて事故っている | ✓ | |
| 値に制約が一切ない/内部の一時変数で外に出ない/ドメインの語彙として名前が付かない | ✓(裸のままで十分。Value Object は過剰) |
整備手順
- ドメインの概念(「寄付額」「支援者ID」「メールアドレス」)を、裸の
int/stringのまま持っている箇所を洗い出す。 - 取り違えると痛い概念は、それぞれ別の型にする(
type SupporterID int64/type Money int64)。関数の引数も、構造体のフィールドも、その型に変える。取り違えは、コンパイルで止まる。 - 検証が要る概念は、非公開フィールドを持つ
struct+ コンストラクタ(NewEmail(s) (Email, error))にし、生成時に一度だけ検証する。外からはNew…経由でしか作れないので、検証を必ず通る。 - 散っていた検証を消し、
New…の一箇所に集める。受け取った側は「型になっている=検証済み」と信じる。 - 複数通貨など単位が絡むなら、
Moneyに通貨も抱かせて、異なる単位の演算を弾く。ただし、やりすぎない——取り違えると痛いもの、検証が散るものにだけ、型を着せる。
親方より
int や string は、いちばん素の型だ。素のままは、速い。考えなくていい。だから、つい、寄付額も、支援者IDも、メールも、ぜんぶ素のままで並べてしまう。並べた瞬間は、ラクだ。
だが、素の型は、意味を持たない。コンパイラには、寄付額も支援者IDも、同じ int にしか見えない。同じ口には、何でも挿さる。いつか、燃料の口に冷却水が繋がる。気づかないまま。
裸の値に、意味の型を着せろ。意味の違うものは、違う型にしろ。検証が要るものは、正しいものしか作れない型にしろ。そうすれば、取り違えは挿さらないし、検証は、作る一箇所で済む。——ひとりで書いていても、型は、お前の代わりに、見張ってくれる。
