よく見る実装
学習教材やサンプルコードで、こんな型定義を見たことはないだろうか。
type User = {
id: number
name?: string
isLogin: boolean
} isLoginフラグでログイン状態を管理し、nameはログインユーザーのみが持つためオプショナルにしている。一見シンプルで分かりやすい。
しかし、この設計には複数の問題がある。サンプルコードはあくまでもサンプルコードであり、実務上のベストプラクティスではない。
状態によってプロパティの有無や振る舞いが変わるなら、フラグで管理するのではなく、別々の型として定義すべきだ。
フラグ型の問題点
①オプショナル地獄
ログインユーザーはnameが必須だが、ゲストユーザーにはnameが存在しない。
この2つの状態を1つの型で表現しようとすると、nameはオプショナルにせざるを得ない。
function greet(user: User) {
if (user.isLogin) {
// user.name は string | undefined のまま
// ログイン済みなら必ず存在するはずなのに、nullチェックが必要
console.log(`こんにちは、${user.name ?? '名無し'}さん`)
}
} ログイン済みユーザーであればnameは必ず存在する。しかし型がそれを表現できていないため、毎回nullチェックを書かなければならない。これがコードベース全体に広がると、本来不要なチェックが至る所に散らばることになる。
②矛盾データを許容してしまう
フラグ型の最大の問題は、あり得ない状態を型が許容してしまうことである。
// 型的には有効だが、ビジネスロジック的にはあり得ない
const invalidUser: User = {
id: 1,
isLogin: true,
name: undefined // ログイン済みなのに名前がない?
} isLogin: trueかつname: undefinedという組み合わせは、ビジネスロジック上あり得ない。
しかし、これは仕組み上発生してしまう可能性を秘めている。
この矛盾データが生成されないことを保証するのは、開発者の努力に委ねられる。
③関数の引数ミスを防げない
購入履歴の取得のような「ログイン済みユーザーのみが実行できる」処理があったとする。
function getPurchaseHistory(user: User): PurchaseHistory[] {
if (!user.isLogin) {
throw new Error('ログインが必要です')
}
return fetchPurchaseHistory(user.id)
} この関数はログイン済みユーザーのみを受け付けるべきだが、型定義上はUser型であれば何でも受け取れてしまう。
「購入履歴を取得できるのはログインユーザーのみ」という制約は自明にも関わらず
わざわざif(!user.isLogin)というログイン判定処理を書かなければいけない。
もしこのif文を書くのを忘れていてもコンパイルエラーにはならないため、実行時に初めてバグを発見できる状態である。
④フラグ操作漏れのリスク
booleanフラグは更新を忘れやすい。
ログイン処理でユーザー情報を設定したがisLoginをtrueにし忘れた、という事態が起こりうる。
// フラグの更新忘れ
function login(guestUser: User, credentials: Credentials): User {
const userData = authenticate(credentials)
return {
...guestUser,
name: userData.name,
// isLogin: true を忘れた!
}
} フラグとデータの整合性を保つ責任は開発者にあり、これは人間の注意力に依存した脆い設計である。
型を分けるとどうなるか
では、状態ごとに型を分けるとどうなるか。
以下はLoginUserとGuestUserを別の型として定義したコードである。
// ログイン済みユーザー
type LoginUser = {
id: number
name: string // 必須
}
// 未ログインユーザー
type GuestUser = {
id: number
} この設計で、先ほどの問題点がどう解決されるか確認する。
①オプショナル地獄 → nullチェック不要になる
function greet(user: LoginUser) {
// user.name は string 型(必須)
// nullチェック不要でそのまま使える
console.log(`こんにちは、${user.name}さん`)
} LoginUser型ではnameは必須プロパティなので、関数内でnullチェックを書く必要がない。
②矛盾データを許容してしまう → そもそも作れない
// コンパイルエラー:name は必須
const invalidUser: LoginUser = {
id: 1,
// name がないのでエラー
} LoginUser型ではnameが必須なので、「ログイン済みなのに名前がない」という矛盾データはコンパイル時に弾かれる。
③関数の引数ミスを防げない → コンパイルエラーで防げる
function getPurchaseHistory(user: LoginUser): PurchaseHistory[] {
// ログイン判定のif文が不要
return fetchPurchaseHistory(user.id)
}
// 呼び出し側
const guest: GuestUser = { id: 1 }
getPurchaseHistory(guest) // コンパイルエラー! 引数の型がLoginUserなので、GuestUserを渡そうとするとコンパイル時にエラーになる。
実行時ではなく、コードを書いている時点でミスに気づける。
④フラグ操作漏れのリスク → フラグがないので漏れようがない
function login(credentials: Credentials): LoginUser {
const userData = authenticate(credentials)
return {
id: userData.id,
name: userData.name,
// フラグがないので「更新忘れ」が発生しない
// name を忘れるとコンパイルエラー
}
} 戻り値の型がLoginUserなので、nameを設定し忘れるとコンパイルエラーになる。フラグの更新忘れという問題自体が存在しない。
型を分けるのは面倒では?
確かにモデリングには時間がかかる。フラグを1つ追加するのと、新しい型を定義するのでは、後者の方が手間だ。
しかし、考えてほしい。型を分けずに得られる「楽さ」は、各所での判定ロジックに転嫁されている。
フラグ型を使うと、そのフラグを参照するすべての箇所でチェックが必要になる。
// フラグ型:使う側が毎回チェック
if (user.isLogin) {
// ログイン済みの処理
} 型を分ければ、このチェックは不要になる。「自前のチェックを各所に書く手間」と「最初に型を設計する手間」、どちらが大きいだろうか?
さらに、フラグ型では整合性チェックを書き忘れても型はエラーを出さない。
型を分ければ、不正なデータはそもそも作れない。安全性の観点でも、型を分ける方が優れている。
ログイン済みユーザーも未ログインユーザーも同一の「ユーザー」として扱いたいときは?
「ログインユーザーもゲストユーザーも同じように処理したい関数がある」という場合は、
Union型のように「複数のものをまとめたより抽象度の高い型」を合成して作成するのが良い。
type User = LoginUser | GuestUser
function getUserId(user: User): number {
return user.id // どちらの型でもidは存在する
} ただし、これは「両方を受け付ける必要がある関数」だけで使う。
基本的には関数が必要な型だけを受け取るようにし、Union型は必要な場面でのみ使う。
同様に気をつけたいケース
今回はUser(ログイン済み / 未ログイン)を例に説明したが、同様のパターンは他にもある。
- メールアドレス:認証済み / 未認証
- 注文:下書き / 確定済み / 発送済み / キャンセル
- ファイル:アップロード中 / アップロード完了 / エラー
- フォーム入力:未入力 / 入力済み / バリデーションエラー
また、isXxxのようなbooleanフラグだけでなく、typeやroleのような属性・種別でも同じことが言える。
-
type: 'admin' | 'member' | 'guest' -
role: 'owner' | 'editor' | 'viewer' -
status: 'draft' | 'published' | 'archived'
これらも「1つの型で複数の状態を表現し、プロパティで区別する」パターンであり、状態によって持つべきプロパティや振る舞いが異なるなら、型を分けることを検討してほしい。
まとめ
「1つの型にフラグを持たせる」設計はサンプルコードでよく見かけるが、それは説明を簡略化するためのコードである。
状態によってプロパティの有無や振る舞いが変わるなら、型を分けるべきだ。
抽象化は常に正義ではない。あえて具体化する(型を分ける)ことで、可読性と安全性が上がるケースがある。
次にコードを書くとき、isXxxフラグやtype・roleのような属性を追加しようとしたら立ち止まり、
「この状態は型で表現できないか?」と自問する習慣をつけてほしい。