転職用のポートフォリオとして、個人でWebアプリ「Hubpin」を作っています。
制作の経緯は「Hubpin 開発記録」に時系列でまとめています。
設計と判断は自分で行い、実装も原則自分で書いています。AIには手順書・レビュー・調査を任せています。
前回、DBから型を生成したら1箇所だけ弱くなった話を書きました。
その日の夕方に、弱くなった場所から実際に何かが入りかけました。
フォームの入力チェックを Zod で書き始めたところです。
カードの種類を3つに絞る部分で、コメントだけの骨格を用意していました。
type: z.enum([/* 3つのどれか */]),
貼った瞬間に、GitHub Copilot のインライン提案が出ました。
type: z.enum(['type1', 'type2', 'type3']), // 提案されたもの
type: z.enum(['link', 'note', 'feed']), // 正しいもの(DBの制約と同じ)
/* 3つのどれか */ というコメントは読んでいて、マイグレーションは読んでいません。
3つならこうだろう、という形で埋めてきました。
どこまで通るのか試しました
採用はしなかったので、通るかどうかは推測のままにできました。
ただ、どこで止まるのかは知っておきたかったので、一時ファイルを作って実際に走らせました。
| 関門 | 結果 |
|---|---|
| エディタの赤線 | 出ない |
tsc --noEmit |
エラー0件 |
eslint |
exit 0。警告すら出ない |
Supabase の Insert 型に代入 |
通る |
最後の1つはこういうことです。
type InsertType = Database['public']['Tables']['items']['Insert']['type']
const smuggled: InsertType = 'type1' // tsc は何も言わない
存在しない値が、DBに書き込む型にそのまま入ります。
止まるのは、クエリがデータベースに届いて check 制約に弾かれた瞬間だけでした。
type text not null check (type in ('link', 'note', 'feed')),
関門が反応しない理由
型検査が見ているのは形が合っているかで、その値が実在するかは見ていないからです。
z.enum から見れば「文字列が3つ」は完全に正しい形です。Insert 型から見ても、その列は string なので 'type1' は正しい文字列でした。
どの関門も、自分の持ち場では正しく動いています。
値の範囲を知っている場所が、この経路上に1つも無かったというだけでした。
前日の判断と、同じ場所でつながりました
Insert 型の type が string なのは、前回書いた生成型の話そのものです。
| 型 | |||
|---|---|---|---|
| 手書きしていた型 | `”link” \ | “note” \ | “feed”` |
| 生成された型 | string |
もし手書きのままだったら、'type1' は代入した時点で tsc が捕まえていました。
型をデータベースに合わせた判断が、AIの誤補完を止める最後の関門も一緒に外していた。
午前の作業と夕方の出来事が、同じ1箇所でつながっていました。
ただ、手書きに戻すのが正解ではありません。
あの型はデータベースが保証していないことを勝手に約束していたので、
置き換えたこと自体は正しかったと今も思っています。
結論はこうなりました。
型をデータベースに合わせたなら、値の範囲を守る層を別に立てる必要がある。
その日、似た形の間違いが5件ありました
同じ日に「信じたら外れた」ものを並べると、5件目だけ性質が違いました。
| 何を信じたか | 間違いは表に出たか |
|---|---|
| 画面の表示 | 出た |
| コントラスト比の数値 | 出た |
| 公式ドキュメント | 出る |
| 自分の原因の見立て | 出た |
| AIの補完 | 出ない |
4件は、どこかに「違う」という信号が出ていました。
枚数が合わない、目で見て分かる、写すとエラーになる、直しても変わらない。
5件目だけが、手前の関門を全部通過します。
対策として「AIを疑う」を採らなかった理由
Copilot は今も使っています。
提案の速さは実際に効いていて、外すつもりはありません。
そのうえで、対策を「疑う」に置くのはやめました。
疑うかどうかは毎回の判断になるので疲れている日に抜けますし、
抜けたときに止める仕組みが無いなら対策としては弱いと考えたからです。
続かないものは仕組みではありません。
置き場所を変えることにしました。
- データベース側に
check制約を置く(今回、唯一止められた層) - アプリの入口(Zod)でも同じ範囲を書く
type: z.enum(['link', 'note', 'feed']),
同じことを2箇所に書いているので、一見すると無駄です。
ただ、どちらかが間違っていれば必ずどちらかで落ちます。
重複しているから検出できる、という形にしました。
おわりに
この日に書いていた Zod のスキーマは、
外れた関門を別の場所に立て直す作業そのものでした。
補完が出した3つの値は採用していないので、実害はゼロです。
残ったのは、自分の作ったチェックの網に目が空いていたという事実のほうでした。
型シリーズはここまでです。
6本を通して出てきたのは、! も : Item も as も、
自分で書いた記号がチェックを黙らせていたという共通点でした。
赤線が消えたときに、なぜ消えたのかを見る。
今のところ、これが自分にできる一番安い対策になっています。
