転職用のポートフォリオとして、個人でWebアプリ「Hubpin」を作っています。
制作の経緯は「Hubpin 開発記録」に時系列でまとめています。
設計と判断は自分で行い、実装も原則自分で書いています。AIには手順書・レビュー・調査を任せています。
型シリーズの続きです。
前回は、生成した型が手書きの型より弱くなった、という話でした。
その穴が、3工程あとに別の形で返ってきました。
編集ページを作っていて、as によるキャストを1箇所だけ書きました。
「これは安全なんだろうか」と気になって、値の流れを追ってみた記録です。
書いたのは1箇所
書いたのは1行です。
type: item.type as ItemInput['type']
データベースから読んだカードの種類を、<select> の初期値として渡すためのものです。
生成された型では string になっているので、そのままでは選択肢の型に入りませんでした。
値がどこへ行くかを追った
安全かどうかは、キャストした値がこのあと何に使われるかで決まると考えて、流れを書き出しました。
DB → 編集ページ(ここで as)→ <select> → 送信 → 検証 → DB
そのうえで、自分のコードで気になる3点を見ました。
| 観点 | 評価 |
|---|---|
| 表示に埋め込まれて悪さをするか | しない。<select> の値にしかならず、React が自動でエスケープする |
| 権限の判定に影響するか | しない。権限の判定に使っているのはユーザーIDのほう |
| 不正な値がそのまま保存されるか | されない。書き戻す前に必ず検証を通る |
この位置なら影響は小さい、と判断しました。
1行のままにしました。
危険になる条件のほうを書き出した
ただ、これは as が一般に安全という話ではありません。
危険になる条件を3つ書き出したら、今回はどれにも当たっていなかった、というだけでした。
1. キャストした値が権限の判定に使われる(role as 'admin' | 'user' が最悪の形)
2. 検証を挟まずに外へ出る
3. HTML や SQL に埋め込まれる
同じ1行でも、置く場所で評価が完全に逆になります。
真逆です。
読む側(画面に出す側)に置いたから、後段の検証が受け止めてくれる。
保存する処理の中に書いていたら、その検証を迂回することになって、意味が反対でした。
as のリスクは「キャストしたこと」ではなく、「その値が次に何に使われるか」で決まる。
これが今回の結論です。
根拠は2つあって、強さが違った
安全だと判断した根拠は、実は2つありました。
- 上流: データベース側に、3つの値しか入らない制約がある
- 下流: 保存する前に、必ず検証を通る
このうち上流は、マイグレーションの書き換えで消えることがあります。
下流のほうは、保存処理の中にあるので残ります。
強さが違うので、コードのコメントには下流のほうを書きました。
将来この行を読む人が「DB に制約があるから大丈夫」と読むと、
制約が外れたときに根拠ごと消えます。
消えたことにも気づけません。
キャストが要らなかった選択肢
そもそもキャストが不要になる書き方もありました。
3つの値を、制約ではなく Postgres の enum 型として持つ方法です。
手元で確認したところ、生成された型には Enums という枠が用意されていました。
enum を定義していれば、その3つの値がここに出てきます。
一方、いま使っている制約のほうは型に反映されず、string のままでした。
| 制約(現行) | enum | |
|---|---|---|
| 型に出るか | 出ない | 出る |
| 値の追加 | 制約を外して付け直す | 型に値を追加する |
| 値の削除 | 簡単 | 事実上できない |
enum にすればキャストは消えますが、値を削除できなくなります。
v1 の仕様は凍結済みで、この3つは要件として固定なので enum 向きではあります。
ただ、いま作り直す理由がないので、今回は採りませんでした。アイデアだけ残してあります。
判断が3工程あとに返ってきた
面白かったのは、この話の出発点が3工程前だったことです。
データベース側で「3つの値のどれか」という制約をかけると決めたのは、設計の工程でした。
そのあと型を手書きから生成に切り替えたとき、
生成された型が保証するのはカラムの構成であって、値の範囲ではないと気づきました。
そして今回、その穴が「キャストが必要になる」という形で出てきました。
判断そのものが間違っていたとは思っていません。
今でも。
ただ、決めた場所と、影響が出る場所が離れていました。
記録がなければ、この1行をなぜ書いたのか、たどれなかったと思います。
