DBから型を生成したら、1箇所だけ手書きより弱くなりました

転職用のポートフォリオとして、個人でWebアプリ「Hubpin」を作っています。
制作の経緯は「Hubpin 開発記録」に時系列でまとめています。
設計と判断は自分で行い、実装も原則自分で書いています。AIには手順書・レビュー・調査を任せています。

前回、: Item と書いた型注釈が誰にも検証されていなかった話を書きました。
その続きです。

CRUDを書き始める前に、supabase gen types でデータベースから型を生成して、
手書きしていた型を置き換えました。
これで注釈が検証される側に回るはずでした。

tsc を走らせたら、エラーがちょうど1件出ました。

目次

弱くなったのはカードの種類でした

error TS2322
  Types of property 'type' are incompatible.
    Type 'string' is not assignable to type '"link" | "note" | "feed"'.

カードの種類を表す type というカラムです。

手書きしていた型 `”link” \ “note” \ “feed”`
生成された型 string

生成型のほうが弱い。

順序が逆だと思いました。
実物から作った型のほうが、手で書いた型より広くなっています。

生成型が保証しているのはカラム構成でした

理由はデータベース側を見たら分かりました。

type text not null check (type in ('link', 'note', 'feed')),

カラムの型は text です。
値を3つに限っているのは、その後ろの check という制約のほうでした。

生成される型が持っているのは「そのテーブルにどんなカラムがあるか」で、
「その列にどんな値が入りうるか」までは含まれていません。
だから textstring になります。

ここは書き方によって変わります。
Postgres には値の範囲そのものを型として定義する enum があって、
そちらを使っていれば生成型にもユニオンとして落ちてきます。

textcheck を付ける書き方だと、制約の情報は型まで届きません。
同じ「3つに限る」でも、写り方が違いました。

つまり tsc が出した1件は、型が食い違っているという報告ではありませんでした。
手書きの型が、データベースの保証していない範囲まで約束していた。
そのずれが、置き換えた瞬間に1件のエラーとして出てきただけでした。

手書きの型のほうが厳しかったのではありません。
根拠がないまま厳しく書いてあった、というのが正確でした。

キャストで消さなかった理由

この赤線は、1行で消せます。

const items = data as Item[]

これを書けば、以降は3種類のどれかとして扱えます。書き心地も戻ります。

ただ、as は「必ず3種類のどれかだ」と主張する記述です。
誰も確かめていません。

check 制約を後から外したとしても、この行は何も言わなくなります。
前回の : Item と同じ場所に戻るだけでした。

なので Pick で必要なカラムだけ切り出して、typestring のまま受けました。

export type Item = Pick<
  Database['public']['Tables']['items']['Row'],
  'id' | 'type' | 'title' | 'description' | 'url' | 'sort_order'
>

いま弱いのを受け入れられるのは、弱いことが見えているからです。

副産物もありました。
select() から取ってくるカラムを1つ減らすと、Pick の側にも赤線が出ます。
取ってきていないものを使っている、がコンパイル時に分かるようになりました。

弱いままにするなら、別の場所で守る必要があります

ここで止めておけない事情があります。

弱くなった型は、根拠がなかった場所が見えたという意味だけではありません。
それまで型が止めていたものを、誰も止めなくなったということでもあります。

"link" | "note" | "feed" と書いてあった間は、それ以外の文字列を入れようとすると
その場で tsc が止めていました。
string になった今は、どんな文字列でも通ります。
止まるのは、実際にデータベースへ届いて check 制約に弾かれた瞬間だけです。

対処は2つあります。どちらを選ぶかは設計の判断。

  • データベース側に制約を足して、生成型を強くする
  • アプリ側で絞る型を別に定義して、入口で検証する

どちらもやらない、という選択肢は事実上ありません。

おわりに

型を生成すれば手書きより良くなる、と思って作業を始めました。
実際に見えたのは、自分が思い込みで書いていた制約のほうでした。

弱くなった1箇所は、劣化ではなく、もともと根拠がなかった場所です。
そこを型で押さえているつもりだった、というのが今回の収穫でした。

そして、この判断の答え合わせは同じ日の夕方に来ました。
外れた関門をまだ立て直していない状態で、
存在しない値が入りかけるところまで行っています。
次回はその話を書きます。

目次