基礎●●●○○

型システム

Type System ・ かたしすてむ

値の種類を検査してプログラムの誤りを実行前に見つける仕組み。静的か動的か、強いか弱いかの設計軸がある。

✦ 30秒で試す — 航海null を発明した本人が、それを10億ドルの過ちと呼んだこの関数に null を渡して動かすと、どうなる?予想してみる

概要

型システムは、プログラム中の値を「数値」「文字列」「ユーザー情報」といった種類(型)で分類し、つじつまの合わない操作 — 文字列に対する割り算、存在しないプロパティへのアクセスなど — を機械的に検出する仕組みです。言語仕様の一部として組み込まれており、検査のタイミングと厳しさは言語ごとに大きく異なります。

日常の開発体験に最も直結する語彙のひとつです。エディタで補完が効くのも、関数の引数を間違えた瞬間に赤線が引かれるのも、裏で型検査器が動いているからです。特に TypeScript の普及以降、「型を書くこと」はコンパイラで機械語に翻訳する言語だけの話ではなく、JavaScript を含む Web 開発全体の標準的な作法になりました。

型システムを理解する鍵は、これを「制約」ではなく「実行せずに得られる保証」と捉えることです。テストが「特定の入力での動作」を確かめるのに対し、型検査はコードを実行せずに、あり得る実行経路をまとめて調べます。型検査を通ったプログラムが実行時に型の矛盾を起こさないことを証明できる型システムを「健全(sound)」と呼び、Haskell や Rust の安全な部分などはこの性質を目指して設計されています。ただし主流の言語の多くは、使いやすさと引き換えに穴を残しています。TypeScript は any や型アサーションで検査を素通りさせられる、意図的に健全でない型システムです。保証の強さは言語ごとに違う、という前提を持っておくと、後述するリファクタリングやドキュメントとしての価値を正しく見積もれます。

なぜ生まれたか

型はもともと、実行効率のための区別として生まれました。1957年の FORTRAN は、変数名の頭文字(I〜N なら整数)で整数と実数を区別し、コンパイラがどちらの機械命令を使うかを決めていました。やがて ALGOL や Pascal を経て、型は「誤りを実行前に見つける」道具としての役割を強めていきます。

型検査がない、あるいは緩い世界では、型の誤りは実行してみるまで分かりません。関数の引数の順番を間違えた、リネームしたプロパティの参照が1か所残っていた。そうした単純ミスが、テストで踏んだ経路でしか発見されず、最悪の場合は本番でユーザーが踏んで初めて発覚します。さらに深刻なのは規模の問題です。数十万行のコードベースで「この関数の引数を1つ増やす」変更をするとき、影響箇所を人力と検索で洗い出すのは現実的でなく、リファクタリングは「怖くて触れない」作業になっていきます。

型システムはこの問題に「実行前の機械検査」で答えます。値の種類と関数の入出力を宣言(または推論)しておけば、コンパイラがすべての利用箇所を照合し、矛盾を残らず列挙してくれます。変更の影響範囲の洗い出しが、grep と度胸の仕事から、検査器が網羅的にやってくれる仕事に変わる — 大規模化・チーム開発化が進むほど、この保証の価値が効いてきます。動的型付けで書かれた巨大な JavaScript / Python / Ruby のコードベースが軒並み型注釈を後付けしていった2010年代の流れ(2012年の TypeScript、2015年の Python 3.5 で入った型ヒントなど)は、この痛みの裏返しです。

詳細

2つの軸 — 静的か動的か、強いか弱いか

型システムの分類には独立した2つの軸があります。1つ目は検査のタイミングで、実行前にソースコードを検査するのが静的型付け(Java、Rust、TypeScript など)、実行中に値の型を確かめるのが動的型付け(Python、Ruby、JavaScript など)です。2つ目は暗黙の型変換をどれだけ許すかで、型の混在を厳しく拒むほど「強い」、こっそり変換して処理を続けるほど「弱い」と呼ばれます。「静的=強い」ではない点が重要で、たとえば Python は動的だが強く("1" + 1 はエラー)、JavaScript は動的かつ弱い("1" + 1 は "11" になる)、C は静的だが弱い(ポインタのキャストで型を素通りできる)という位置付けになります。ただし「強い・弱い」には合意された厳密な定義がなく、人によって「暗黙の変換の多さ」「型の穴の有無」「実行時に型を確かめるか」など指すものが違います。議論では、何を指して強い・弱いと言っているのかを確かめるのが安全です。

動的 — 実行中に検査静的 — 実行前に検査強い弱いPython / Ruby動的だが混在に厳格JavaScript暗黙変換が多いRust / Haskell最も厳格な保証Java / Go静的・強めの本流C静的だがキャストで素通りTypeScript漸進的 — 両側を橋渡し縦軸は暗黙の型変換をどれだけ拒むか。位置はおおまかな目安
静的×動的・強い×弱いの2軸マップ — 「静的=強い」ではない

構造的型付けと名前的型付け — 何をもって「同じ型」とするか

もう1つの設計の分かれ目が、2つの型が互換かどうかを何で決めるかです。Java や C# は名前的型付け(nominal typing)で、class Point { x; y } と class Size { x; y } は中身が同じでも別の型です。互換にしたければ、同じインターフェイスを実装すると明示的に宣言します。一方 TypeScript や Go のインターフェイスは構造的型付け(structural typing)で、必要なプロパティやメソッドをそろえていれば、宣言がなくても互換とみなします。

構造的型付けは、外部ライブラリの型や、API から受け取った JSON の形にそのまま型を当てられる手軽さがあり、JavaScript の緩やかな世界に型を後付けする TypeScript には欠かせない選択でした。その代わり、「ユーザー ID」と「注文 ID」がどちらも string なら取り違えても検査を通ってしまいます。TypeScript ではこれを防ぐため、string & { __brand: "UserId" } のような目印を足して、構造的な世界の中で名前的な区別を作る「ブランド型」という技法が使われます。

型推論 — 書かなくても検査される

「静的型付け=型をたくさん書かされる」というイメージは、現代では半分誤解です。多くの言語は型推論を備えており、文脈から型を自動で導きます。

const prices = [980, 1280];          // number[] と推論される
const total = prices.reduce((a, b) => a + b, 0); // total は number
total.toUpperCase(); // エラー: number に toUpperCase はない

1行も型注釈を書いていないのに、最後の行はコンパイラの型検査で弾かれます。1970年代に ML で実用化された Hindley–Milner 型推論など、関数型言語が開拓した推論技術が主流言語に降りてきた結果、「動的型付けの書き心地で静的検査の保証を得る」ことが現実になりました。実務では、関数の引数・戻り値など境界には明示的に型を書き、内部の変数は推論に任せるのがバランスの良い作法とされています。なお、総称的なコンテナや関数を型安全に書くためのジェネリクスは、推論と組み合わさることで真価を発揮する仕組みです。

TypeScript と漸進的型付け

TypeScript の成功を支えたのが漸進的型付け(gradual typing)という設計です。すべてのコードに一度に型を付けるのではなく、any 型を「型検査を諦める箇所」として認めることで、既存の JavaScript 資産に少しずつ型を足していけます。動的な世界と静的な世界を地続きにしたこの割り切りが、巨大な既存エコシステムへの型の後付けを可能にしました。Python の型ヒント + mypy、Ruby の RBS も同じ思想です。ただし TypeScript の型は実行時には消える(トランスパイラが型注釈を剥がして素の JavaScript を出力する)ため、実行時の値が型宣言と食い違う可能性 — 特に API レスポンスなど外部から来るデータ — には検査が及ばず、境界でのバリデーションが別途必要になる点は典型的な落とし穴です。

型検査が実行と分離しているぶん、パイプラインのどこで走らせるかも運用の設計対象になります。

エディタが編集中に検査し補完・即時指摘コミット前フックで型検査CIで全体を再検査して合流を守るトランスパイルで型を除去して実行コードへ
型検査が開発パイプラインで走る箇所 — 早い段階ほど修正が安い

エディタでの即時フィードバックが日々の開発体験を作り、CI/CD での検査がチーム全体の合流地点を守る、という役割分担です。

型は仕様でありドキュメントである

型の価値は誤り検出だけではありません。function charge(userId: UserId, amount: Yen): Receipt という署名は、「この関数は何を受け取り何を返すか」を語る、コンパイラが鮮度を保証するドキュメントです。コメントは実装とずれても誰も気づきませんが、型はずれた瞬間にビルドが落ちます。さらに進んで「不正な状態を型で表現不能にする」設計 — 注文の状態を文字列ではなく「未払い/支払済み/発送済み」の3択の型にすれば、タイポも未知の状態もコンパイル時に消えます。テストが振る舞いの正しさを、型が構造の整合性を守る、という補完関係で捉えるのが実務的な理解です。

null 安全 — 10億ドルの過ちへの答え

null 参照の発明者トニー・ホーアが、1965年の ALGOL W での導入を振り返って2009年の講演で自ら「10億ドルの過ち」と呼んだように、「値があるはずの場所に null が紛れ込み、実行時に落ちる」バグは長年ソフトウェアの定番でした。現代の型システムはこれに「null になり得るかどうかを型で区別する」ことで答えています。Kotlin の String?、Rust の Option、TypeScript の string | null(strictNullChecks 有効時)はいずれも、null の可能性がある値を確認なしに使おうとするとコンパイルエラーにし、null チェックを済ませた分岐の中でだけ安全に使わせます。

これを支えるのが、フロー解析による絞り込み(narrowing)です。型検査器はコードの流れを上から追い、if (name === null) return のあとでは「ここに来るなら name は null ではない」と判断して、型を string | null から string に狭めます。if (name !== null) { … } なら、狭まるのはブロックの内側だけで、} を出ると元に戻ります。下の部品で、判定のブロックをコードの行のあいだに差し込み、各行の型がどう変わるかを確かめてください。最後に name!(null ではないと断言する型アサーション)を使って null を渡すと、型検査を通ったのに実行時に落ちる、という型の穴も体験できます。

⚡ 体験: null かもしれない name — 判定を差し込んで型を絞る
コードこの行での name の型function greet(name: string | null) {console.log("こんにちは")string | nullconst n = name.lengthstring | nullreturn name.toUpperCase()string | null}判定のブロック(つかんで差し込む・差し込んだものは押すと外れる)if (name === null) return "ゲスト"if (name !== null) {name = name ?? "ゲスト"name!(null ではないと断言)
渡す値

赤く光る2行は、name が null かもしれないので型エラーです。下のブロックをつかんで、コードの行のあいだに差し込んでみてください。

「null かもしれない」という暗黙の不安を、型で明示的な取り扱い義務に変えたことは、近年の型システムが実務にもたらした最大の成果のひとつです。その保証を守るには、! や as、any で検査器を黙らせる箇所を最小限にし、外から来るデータは境界で実際に確かめる必要があります。

試問

画面には、name: string | null(文字列か null)を受け取る短い関数があり、各行の右にその行での name の型が出ます。name を使う行は、null かもしれないので赤い型エラーになっています。下の判定のブロック(null を確かめる if・早期 return・既定値、確かめずに「null ではない」と断言する name!)をつかんでコードに差し込み、渡す値を選んで実行できます。

✦ 命題挑戦中

型検査は通るのに、null を渡すと実行時に落ちる関数を作れ

  • 型エラー(赤い行)が 0。型検査を通っている
  • 渡す値を null にして実行する
  • それでも実行時エラーで落ちる
⚡ 体験: null かもしれない name — 判定を差し込んで型を絞る
コードこの行での name の型function greet(name: string | null) {console.log("こんにちは")string | nullconst n = name.lengthstring | nullreturn name.toUpperCase()string | null}判定のブロック(つかんで差し込む・差し込んだものは押すと外れる)if (name === null) return "ゲスト"if (name !== null) {name = name ?? "ゲスト"name!(null ではないと断言)
渡す値

赤く光る2行は、name が null かもしれないので型エラーです。下のブロックをつかんで、コードの行のあいだに差し込んでみてください。