エンジニアを目指す初学者に向けて、わかりやすく解説したブログです。

isLoginフラグではなく、型で状態を語れ

よく見る実装

学習教材やサンプルコードで、こんな型定義を見たことはないだろうか。

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 を忘れた!
  }
}

フラグとデータの整合性を保つ責任は開発者にあり、これは人間の注意力に依存した脆い設計である。

型を分けるとどうなるか

では、状態ごとに型を分けるとどうなるか。

以下はLoginUserGuestUserを別の型として定義したコードである。

// ログイン済みユーザー
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フラグだけでなく、typeroleのような属性・種別でも同じことが言える。

  • type: 'admin' | 'member' | 'guest'
  • role: 'owner' | 'editor' | 'viewer'
  • status: 'draft' | 'published' | 'archived'

これらも「1つの型で複数の状態を表現し、プロパティで区別する」パターンであり、状態によって持つべきプロパティや振る舞いが異なるなら、型を分けることを検討してほしい。

まとめ

「1つの型にフラグを持たせる」設計はサンプルコードでよく見かけるが、それは説明を簡略化するためのコードである。
状態によってプロパティの有無や振る舞いが変わるなら、型を分けるべきだ

抽象化は常に正義ではない。あえて具体化する(型を分ける)ことで、可読性と安全性が上がるケースがある。

次にコードを書くとき、isXxxフラグやtyperoleのような属性を追加しようとしたら立ち止まり、
「この状態は型で表現できないか?」と自問する習慣をつけてほしい。