AI 時代のコード品質戦略 - バグに強いコードを型でデザインする
こんにちは、ダイニーの ogino です。
この記事では、複雑なビジネスロジックを正確に実装するために、TypeScript の型を最大限活用する考え方を解説します。
なぜ AI 時代にコード品質を考えるのか
AI エージェントが自律的にコードを書いてくれる時代に、人間基準のコード品質をわざわざ考える意義は何でしょうか?最大のボトルネックであるコードレビューの負担を減らすため、というのが私の考えです。
現状の AI のレベルでは、プログラミングをゼロから完全に任せきりにすることはできません。まだ今のところは人間のレビューが必要です。しかし、人のレビューは AI のコード生成速度と比べて圧倒的に遅く、AI を取り入れようとすると一番のボトルネックになります。
だとすればレビュアーの時間を無駄にしないように最適化すべきであり、そのためには以下の点が重要です:
- コードを読みやすくすること
- レビューするまでもないコードを自動チェックで弾くこと
コードの自動チェックには常に False Positive や False Negative の可能性があるので、バランスを取って厳しさを設定する必要があります。
チェックを通る | チェックに落ちる | |
---|---|---|
バグではない | ✅ True Negative | ❌ False Positive |
バグがある | ❌ False Negative | ✅ True Positive |
False Negative はレビュアーの負担に、False Positive は実装者の負担に繋がります。そのため、AI 利用を前提とするなら False Negative をより減らす方向に倒すべきです。つまり、自動チェックを極力厳しくすべきです。
そして今のところ、最も確実で手っ取り早い自動チェックの手法は、テストと型チェックです。だから我々は、readable, testable, checkable なコードを書くように AI を誘導する必要があります。
自己完結的なコードはバグを起こしにくい
コードを改修する際には大抵、依存しあう複数の箇所を合わせて変更します。この時に考慮や更新の漏れがあると、バグに繋がります。依存する情報が小さな範囲にまとまっていて自己完結的であれば、考慮漏れをしにくくなり、バグを起こしにくいと言えます。
具体例として、会計時の割引額を計算するロジックを考えてみましょう。仕様は以下の通りとします。
- 割引には「パーセント割引」または「固定額割引」の 2 種類だけがある
- 割引額や割引率のパラメータを自由に設定できる
- パーセント割引に限り、割引の上限額を設定できる
- 割引の種類は将来的に増える可能性がある
下記のコードは、自己完結的ではない実装の例です:
interface Discount {
rate?: number
upperLimit?: number
fixedAmount?: number
}
const getDiscountAmount = (subtotal: number, discount: Discount): number => {
if (discount.rate !== undefined) {
return Math.min(
subtotal,
discount.upperLimit ?? Infinity,
subtotal * discount.rate,
)
}
if (discount.fixedAmount !== undefined) {
return Math.min(subtotal, discount.fixedAmount)
}
}
まず、この関数は型チェックが通りません。どちらの if
文の条件にもかからない場合に return しないので、返り値型の number
に反しているからです。これは、discount
が固定額割引でもパーセント割引でもない場合に対応するので、仕様上存在してはいけないはずです。しかし、その制約は上のコードのどこにも表現されていません。
// 仕様上許されないが型チェックを通ってしまう Discount の値
// ❌ パーセント割引でも固定額割引でもない
const illegal1: Discount = {}
// ♣️ パーセント割引と固定額割引両方の性質を持つ
const illegal2: Discount = { fixedAmount: 1000, rate: 0.1, upperLimit: 500 }
そのため、getDiscountAmount
が正しく動くか否かは、「呼び出し側が常に仕様通りの discount
を渡す」という暗黙の前提に依存しています。
この問題は、未定義のケースに対して例外を投げるように変えたとしても解決しません。その例外が発生するのはプログラマのミスによるバグであり、ハンドルのしようが無いからです。
例外を使うと、バグが起きた時のダメージを少し抑えることはできますが、バグを未然に防ぐことは全くできません。
入力パターン全域で関数を正しく定義する
前節の getDiscountAmount
関数は、取りうる引数の一部に対してしか返り値が定義されていませんでした。そのような関数のことを部分関数 (partial function) と呼びます。
部分関数は自己完結的ではありません。未定義のパターンに当たるか否かは、呼び出し側に依存するからです。ドメインロジックの中に部分関数が現れるのは、典型的なバグの兆候と言えます。
逆に、全ての引数に対して返り値が(正しい型で)定義されている関数を全域関数 (total function) と呼びます[1]。
左: getDiscountAmount
, 右: Math.sqrt
部分関数を全域関数に変える方法は、「入力型を狭める」もしくは「出力型を広げる」の 2 種類に分かれます。
出力型を広げるにはユニオンを使えばいいだけなので、何も考えず簡単に実践することができます。
const getDiscountAmount = (subtotal: number, discount: Discount): number | undefined => {
if (discount.rate !== undefined) { /** ... **/ }
if (discount.fixedAmount !== undefined) { /** ... **/ }
return undefined
}
これにより、「割引額を計算できるケース」と「できないケース」があることが型で明示されるので、呼び出し側がハンドルし忘れる恐れが無くなります。しかしそれは、getDiscountAmount
を呼び出すたびに毎回必ず両方のケースを考慮する手間が増える、ということでもあります。
仕様上起こり得ないはずのケースに対して、そこら中に防御的なコードを書き散らすのでは、根本的な解決になっていません。
そこで、もう一つのアプローチである「入力型を狭める」方法を考えてみましょう。これには少し工夫が必要になります。なぜなら、型の足し算のようなことはユニオンで容易にできる一方で、型の引き算に相当するものは無いからです。
つまり、緩い型に後付けで制限を加えるのは難しいと言えます。そのため次の節では、小さな型を組み立てることで、仕様にジャストフィットする型を作る方法を紹介します。
全てのパターンを Discriminated Union で分割して考える
TypeScript には discriminated union と呼ばれる特殊なユニオン型があります。これを知らなければ、型安全なコードを書くことはほぼ不可能と言えるほど重要な概念です。
パターンを漏れや重複無く網羅する
ここでもう一度、割引の仕様を振り返ってみましょう:
- 割引には「パーセント割引」または「固定額割引」の 2 種類だけがある
- 割引額や割引率のパラメータを自由に設定できる
- パーセント割引に限り、割引の上限額を設定できる
この仕様を言葉通り素直に表現すると、次のような型になるのではないでしょうか。
// 割引には「パーセント割引」または「固定額割引」の 2 種類だけがある
type Discount = PercentageDiscount | FixedDiscount
interface PercentageDiscount {
rate: number // 割引率のパラメータを設定できる
upperLimit: number // パーセント割引に限り、割引の上限額を設定できる
}
interface FixedDiscount {
fixedAmount: number // 割引額のパラメータを設定できる
}
ここで更に、ユニオンの中のどの種類か判別するタグ (discriminator) を付け加えると、Discount
型は discriminated union になります。
type Discount = PercentageDiscount | FixedDiscount
interface PercentageDiscount {
kind: "percentage" // 👈 discriminator
// ...
}
interface FixedDiscount {
kind: "fixed" // 👈 discriminator
// ...
}
この Discount
型には、以下のような不正データを代入することが不可能になります。
-
rate
,fixedAmount
のどちらのプロパティも持たない -
PercentageDiscount
とFixedDiscount
の両方に当てはまる
つまり、仕様を型レベルで守ることが出来ます。
条件分岐で型を絞り込むときに、discriminated union はコンパイラから特殊な扱いを受けます。それを活かして、getDiscountAmount
を下記のように実装することができます
const getDiscountAmount = (subtotal: number, discount: Discount): number => {
switch (discount.kind) { // 👈 discriminator で分岐する
case "percentage":
// この分岐内では `discount` の型が `PercentageDiscount` に絞り込まれる
// discount.upperLimit や discount.rate に安全にアクセスできる
return Math.min(subtotal, discount.upperLimit, subtotal * discount.rate)
case "fixed":
// この分岐内では `discount` の型が `FixedDiscount` に絞り込まれる
return Math.min(discount.fixedAmount, subtotal)
}
// ✅ 全てのケースを網羅していることをコンパイラが推論してくれる
// ここでは `discount` の型が `never` に絞り込まれる
}
この実装の最大の利点は、可能な全てのパターンを漏れ無く重複無く考えることを強制してくれることです。例えば、以下のような実装ミスをすると型エラーで弾いてくれます。TS Playground 上で試してみてください。
-
kind
の値に誤字を混ぜる -
case "percentage"
の分岐の中でdiscount.fixedAmount
にアクセスする -
switch
の中の分岐をどれか削除する -
Discount
型に新しい種類を追加し、getDiscountAmount
の更新を忘れる
パターンの組み合わせを数える
文脈によっては、discriminated union のことを 直和型 (sum type) と呼ぶことがあります。またその場合、オブジェクト型のことを 直積型 (product type) と呼びます。
型が取りうるパターン数を考えると、直和型のパターンは足し算で、直積型のパターンは掛け算で増加します。そのため、直和型なしで型を定義すると、必要以上にパターン数の多い型ができます。つまり、不正な状態を含む型ができてしまいます。
ここで言う「パターン数」とは、「条件分岐の絞り込みによって何個の型に分割できるか」を指します。例えば number | undefined
型は以下のように型を絞り込むことができるので、2 パターンと数えます。
const f = (x: number | undefined) => {
if (x === undefined) {
// x: undefined
} else {
// x: number
}
}
最初に挙げたナイーブな Discount
型の例をもう一度見てみましょう:
interface Discount {
rate?: number
upperLimit?: number
fixedAmount?: number
}
この型には、各プロパティが有るか無いかの組み合わせが
rate | upperLimit | fixedAmount | ドメイン上の意味 |
---|---|---|---|
❌ | ❌ | ❌ | ??? |
❌ | ❌ | ✅ | 固定額割引 |
❌ | ✅ | ❌ | ??? |
❌ | ✅ | ✅ | ??? |
✅ | ❌ | ❌ | ??? |
✅ | ❌ | ✅ | ??? |
✅ | ✅ | ❌ | パーセント割引 |
✅ | ✅ | ✅ | ??? |
一般的に、直積型(オブジェクト型)の総パターン数は、各プロパティ型のパターン数の積になります。
それに対して直和型の総パターン数は、ユニオンの各メンバー型のパターン数の和になります。
下記の PercentageDiscount
と FixedDiscount
のパターン数はそれぞれ 1 なので、Discount
のパターン数は
type Discount = PercentageDiscount | FixedDiscount
interface PercentageDiscount {
kind: "percentage"
rate: number
upperLimit: number
}
interface FixedDiscount {
kind: "fixed"
fixedAmount: number
}
直積型の Discount
の 8 パターンの内、ドメイン的に意味を持つのは 2 つしかありません。その 2 つだけを取り出したのが直和型の Discount
であり、だからこそ不正なケースを考慮しなくて済む、と理解することができます。
Discriminated Union と通常のユニオンの違い
オブジェクト型同士のユニオンを取る時は、常に discriminated union にしましょう。discriminator を持たないただのユニオンでは、型の絞り込みが無駄に難しくなるからです。
これは、部分的に共通するプロパティを持つ型同士のユニオンを取った時に特に顕著になります。
type Discount = PercentageDiscount | PercentageDiscountWithMin | FixedDiscount
interface PercentageDiscount {
rate: number
upperLimit: number
}
interface PercentageDiscountWithMin {
rate: number // 👈 `rate` プロパティを共通して持つ
condition: { minSubtotal: number }
}
interface FixedDiscount {
fixedAmount: number
}
const getDiscountAmount = (subtotal: number, discount: Discount): number | null => {
if ("rate" in discount) { // ❌ これだけでは型を 1 つに絞り込めない
if ("upperLimit" in discount) { // ❌ 条件分岐のネストが発生する
return Math.min(subtotal, discount.upperLimit, subtotal * discount.rate)
}
return subtotal >= discount.condition.minSubtotal ? subtotal * discount.rate : null
}
if ("fixedAmount" in discount) {
return Math.min(subtotal, discount.fixedAmount)
}
return null
}
また、厳密に言えば上記のコードは型安全ではありません。下記のような引数を渡すと、型チェックが通るにも関わらず実行時に型エラーが発生します。 (TS Playground)
getDiscountAmount(1000, { rate: 0.2, fixedAmount: 100 })
// 🚨 [ERR]: Cannot read properties of undefined (reading 'minSubtotal')
この引数 { rate: 0.2, fixedAmount: 100 }
は FixedDiscount
型に代入できるので、 Discount
型にも代入できます。しかし余計なプロパティ rate
を持っているため、本来意図していない条件分岐 if ("rate" in discount)
に入り込んでしまいます。このように、 in
を使った型の絞り込みには僅かながら危険が伴います[2]。
discriminated ではないユニオン型には上記のデメリットが付きまとい、それに対して特にメリットがありません。
Discriminated Union とサブクラス多相の違い
オブジェクト指向に慣れ親しんだ人であれば、Discount
を次のように実装する方が自然に感じるかもしれません。
interface Discount {
getDiscountAmount(subtotal: number): number
}
class PercentageDiscount implements Discount {
constructor (readonly rate: number, readonly upperLimit: number) {}
getDiscountAmount(subtotal: number): number {
return Math.min(subtotal, this.upperLimit, subtotal * this.rate)
}
}
class FixedDiscount implements Discount {
constructor (readonly fixedAmount: number) {}
getDiscountAmount(subtotal: number): number {
return Math.min(subtotal, this.fixedAmount)
}
}
interface が完璧に抽象化に成功していて、getDiscountAmount
以外必要無いとしたら、この実装で何の問題もありません。
しかし、Discount
のデータをシリアライズしたり、UI 上に表示したりする必要に迫られたらどうでしょうか?
const getDescription = (discount: Discount): ReactElement | null => {
if (discount instanceof PercentageDiscount) {
return <span>{discount.rate * 100}%オフ
<small>※上限{discount.upperLimit.toLocaleString()}円</small>
</span>
}
if (discount instanceof FixedDiscount) {
return <span>{discount.fixedAmount.toLocaleString()}円オフ</span>
}
return null
}
この関数は子クラスのデータ構造にアクセスしなければならないので、instanceof
を使って分岐しています。しかし Discount
のサブクラスが PercentageDiscount
と FixedDiscount
だけとは限らないので、コンパイラが網羅性チェックをすることができません。
この問題を避けるために getDescription
を Discount
のメソッドとして追加する方法が考えられます。しかし、ドメインモデルに外側の関心を持ち込むことになるので、良い解決方法ではありません。
他の言語における Discriminated Union
discriminated union は特に真新しい概念でもなければ TypeScript 固有の機能でもありません。もし興味があれば、他の言語と比較してみると理解が深まるはずです。
Haskell
discriminated union は元々、 代数的データ型 (Algebraic Data Type; ADT) を構成する 直和型 (sum type) に由来するものです。ADT は Haskell など ML 系言語の型システムの基礎になっています。
data Discount
= Percentage { rate :: Double, upperLimit :: Int }
| Fixed { amount :: Int }
getDiscountAmount subtotal discount = case discount of
Percentage {rate = r, upperLimit = limit} ->
min subtotal (min (fromIntegral limit) (subtotal * r))
Fixed {amount = amt} ->
min subtotal (fromIntegral amt)
Rust
一方 Rust では代数的データ型という仰々しい名前を前面に出さず、直和型を enum
に擬態させることで親しみやすい雰囲気が出ています。
enum Discount {
Percentage { rate: f64, upper_limit: u32 },
Fixed { amount: u32 },
}
fn get_discount_amount(subtotal: f64, discount: &Discount) -> f64 {
match discount {
Discount::Percentage { rate, upper_limit } =>
subtotal.min(*upper_limit as f64).min(subtotal * rate),
Discount::Fixed { amount } =>
subtotal.min(*amount as f64),
}
}
Kotlin
クラスベースの言語で直和型を再現する場合、sealed class を使う方法がよく知られています。
sealed class Discount {
data class Percentage(val rate: Double, val upperLimit: UInt) : Discount()
data class Fixed(val amount: UInt) : Discount()
}
fun getDiscountAmount(subtotal: Double, discount: Discount): Double =
when (discount) {
is Discount.Percentage ->
minOf(subtotal, discount.upperLimit.toDouble(), subtotal * discount.rate)
is Discount.Fixed ->
minOf(subtotal, discount.amount.toDouble())
}
TypeScript (再掲)
上記に挙げた言語は全て、直和型を分解して中身のプロパティを取り出すためのパターンマッチ式を持っています。TypeScript でそれに相当する構文はありませんが、型推論の narrowing によって実質同じことをやっている、と見ることができます。
type Discount =
| { kind: "percentage", rate: number, upperLimit: number }
| { kind: "fixed", fixedAmount: number }
const getDiscountAmount = (subtotal: number, discount: Discount): number => {
switch (discount.kind) {
case "percentage":
return Math.min(subtotal, discount.upperLimit, subtotal * discount.rate)
case "fixed":
return Math.min(discount.fixedAmount, subtotal)
}
}
型だけで守りきれない部分を Smart Constructor で閉じ込める
ここまで、型レベルでドメイン上のルールを確実に守らせる方法を説明してきました。しかし、型だけでは表現できないルールというのも当然存在します。その場合は、「特定の関数 (smart constructor) を通してしか値を作ることができない型」を定義するのが有効です。
例えば、パーセント割引の設定値は 0 < rate < 1
を満たすべきですが、これを型だけで強制することはできません。そこで、次のような Ratio
クラスを定義します。
class Ratio {
// 何かしら private プロパティを定義することで、nominal type にする
#__nominal: unknown
// private constructor にすることで、継承による抜け道を潰す
private constructor(readonly value: number) {}
// valiation に成功すれば Ratio を、そうでなければ失敗を示す値を返す
static create(value: number): Ratio | null {
const isValid = 0 < value && value < 1
return isValid ? new Ratio(value) : null
}
}
この Ratio
型に代入可能な値は、Ratio.create
以外の方法では作ることができません (TS Playground で試してみてください) 。つまり、Ratio
型の値が与えられた時、その値は必ず validation を通過していることが保証されます。一度作った Ratio
型の値を引き回すようにすれば、コードの各所で防御的に validation をする必要が無くなります。
TypeScript の型の互換性は structural subtyping によって決まるので、全く同じ型のプロパティを持っていれば Ratio
型に代入することができてしまいます。しかし、private なプロパティを持ったクラスは例外的に nominal type になります。上の実装はその性質を利用したものです。
ところで、nominal type を擬似的に再現する方法はもう一つあり、 branded type という名前でよく知られています。これについては既に多くの解説があるので、深く触れません。
declare const brand: unique symbol;
export type Ratio = number & { [brand]: unknown };
export const createRatio = (value: number): Ratio | null => {
const isValid = 0 < value && value < 1
return isValid ? value as Ratio : null
}
AI を型で縛る
本記事の考え方をより大きな規模のコードで実践した例として、下記のレポジトリを作りました。Balatro というカードゲームの一部を再現実装したもので、非常に複雑なドメインロジックを持っています。
これは同時に、Claude Code に全てコードを書かせる実験にもなり、静的型言語との相性の良さを改めて実感しました。
人に配慮しないルール設定
Claude Code と Opus 4 を本格的に使うまで、「AI が書くコードの品質は低すぎて、信頼できない」という所感でしたが、それが大きく変わりました。
今ではむしろ、人が下手にコードを書かない方がコード品質が向上する印象さえ抱いています。
上のレポジトリでは AI だけがコーディングをするという前提で、一切配慮や妥協をせずにアグレッシブな tsconfig や lint ルールを敷いています。主なところでは以下の通りです:
- 型安全性を壊す escape hatch (
any
,as
,is
) を禁止 -
if
,let
,try
を禁止 -
switch
の網羅性チェックを妨げるdefault
節を禁止 - noUncheckedIndexedAccess を有効化
これと同じルールを人が書くコードベースに導入しようとすれば、間違いなく反発に合うでしょう。危険な as
も、branded type のように用法を守って使えば便利なことがあります。let
を無くした宣言的なコードは概して読みやすいですが、局所的に副作用を使った方がむしろ読みやすいアルゴリズムもあります。
しかし、相手が AI なら話は別です。そうした厄介なケースバイケースの判断を任せても、良い結果にはなりません。妥協して許した例外ケースが散らばっていると、それを真似してあっという間に増殖していきます。だからこそ、多少の false positive を受け入れてでも厳しいルールを設定すべきです。
そしてこのチェックを通すには、最低限で本記事に書いた程度の内容は実践できる能力が必要です。以前のモデルやエージェントでは、問題を誤魔化して回避しようとするだけでした。一方 Claude Code と Opus 4 の組み合わせだと、大きく詰まることなく根本解決をできている様子でした。
Check, don't prompt
CLAUDE.md に色々な規約を書いても、実際にはほとんど守ってくれません。そこで、上記のレポジトリではチェックを極力自動化するようにしています:
- テスト、型チェック、リント、デッドコード削除を pre-commit hook で強制
- commit body に概要と背景を書くことを commitlint で強制
これにより、かなり大部分の問題は人間が指摘するまでもなく弾かれるようになりました。
すると今まで以上に頻繁にチェックを回すようになるので、ツールチェーンの実行速度の重要性が増してきます。そこは最近のネイティブツールや TypeScript Go に期待したいところです[3]。
疑問駆動開発
関数を total にして、discriminated union を積極的に使うと、ドメインロジックの大部分が型に表れます。その結果、実装コードを細かく読まなくても、型を眺めるだけで破綻している箇所がわかりやすくなります。
そういった怪しい箇所を見つけ次第とりあえず疑問をぶつければ、解決のための試行錯誤は AI にほぼ丸投げすることができます。
まとめ
バグを減らすために、自己完結的で total な関数を書きましょう。
ユニットテストをするために依存関係の見直しが必要なのと似たように、関数を total にするには型レベルで設計を見直さなければなりません。そのために最も重要な道具が discriminated union です。
なお本記事の内容は、以下のブログに強く影響を受けています。型レベルでの設計について、本記事で触れられていないトピックが多くあるので、ぜひ原典を読んでください。
最後に
ダイニーでは、型チェックやその他のツールを活かした AI 活用を推進しています。ご興味をお持ちの方は、以下のページをご覧ください。
-
より正確には、「関数
f: (arg: X) => Y
が total である」とは、「型X
に assignable な任意の値x
に対して、f(x)
が値y
を返して、y
は 型Y
に assignable である」ことを指します。 ↩︎ -
実際のところ、余計なプロパティが混入していてそれが他の型と衝突する、というケースは非常に稀です。そのため、型の健全性を多少犠牲にしてでも利便性を取る設計になっています。 https://github.com/microsoft/TypeScript/pull/15256 ↩︎
-
型情報を使った lint をするためには TypeScript の API を使うか、もしくは linter が型チェッカー相当のものを自前で実装する必要があります。TypeScript Go の API として今のところ有力なのは IPC であり、本当に速度が改善するか少し怪しい状況です。また、型チェッカーを自前で実装するアプローチは oxc が実験中です。 ↩︎
Discussion