17
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【TypeScript】条件分岐のベストプラクティス ── ネスト・switch・フラグ引数を卒業しよう

17
Posted at

■ この記事はこんな人におすすめ

  • TypeScriptでifのネストやswitchを多用しているが、読みにくいと感じている人
  • enumを使っているが、ケース追加時の漏れが怖い人
  • 「フラグ引数」が何なのか、なぜ避けるべきかを知りたい人
  • 保守しやすい条件分岐の書き方をマスターしたい人

■ この記事で得られること

  • 条件分岐のネストを早期リターンで解消できるようになります
  • switchの代わりに Record<Enum, T> のmapで型安全な分岐が書けるようになります
  • interface設計の考え方をTypeScriptで実践できるようになります
  • フラグ引数のデメリットと3つの改善パターンを理解できます

■ 参考書籍


1. 結論

条件分岐の書き方次第で、コードの可読性・保守性は大きく変わります。

この記事では、TypeScriptで条件分岐を扱う際の4つの重要ポイントを解説します。

  1. 条件分岐のネストを避けよう(早期リターン)
  2. switch構文に頼りすぎないRecord<Enum, T>のmapを使う)
  3. interface設計の考え方を取り入れよう(ハンドラmapパターン)
  4. フラグ引数の使用は控えよう(関数分割 / enum / mapで解決)

2. 具体的にやること

■ ① 条件分岐のネストは「早期リターン」で解消する
■ ② switchの代わりに Record<Enum, T> のmapを使う
■ ③ ロジックが複雑な場合は「interface設計(ハンドラmap)」を使う
■ ④ booleanや数値でのフラグ引数は使わない


3. 各項目の説明

■ ① 条件分岐のネストは「早期リターン」で解消する

ネストが深いと、ロジックの全体像が一瞬で把握できなくなります。

❌ NG: ネストが深いコード

type User = {
  id: number;
  name: string;
  isActive: boolean;
  role: 'admin' | 'member' | 'guest';
  emailVerified: boolean;
};

function canAccessDashboard(user: User | null): boolean {
  if (user !== null) {
    if (user.isActive) {
      if (user.emailVerified) {
        if (user.role === 'admin') {
          return true;
        } else {
          if (user.role === 'member') {
            return true;
          } else {
            return false;
          }
        }
      } else {
        return false;
      }
    } else {
      return false;
    }
  } else {
    return false;
  }
}

✅ OK: 早期リターンで解消

function canAccessDashboard(user: User | null): boolean {
  if (user === null) return false;
  if (!user.isActive) return false;
  if (!user.emailVerified) return false;

  return user.role === 'admin' || user.role === 'member';
}

ポイント: 「ガード節(Guard Clause)」とも呼ばれる手法です。条件を満たさないケースを先に弾くことで、本題のロジックをフラットに保てます。


■ ② switchの代わりに Record<Enum, T> のmapを使う

switch文は新しいケースが追加されたときに型エラーとして検知できないという致命的な弱点があります。

❌ NG: switchを使った実装

enum MagicType {
  FIRE = 'fire',
  WATER = 'water',
  THUNDER = 'thunder',
}

type MagicResult = {
  attack: number;
  tpCost: number;
  isAllTarget: boolean;
};

function getMagicInfo(type: MagicType): MagicResult {
  switch (type) {
    case MagicType.FIRE:
      return { attack: 10, tpCost: 30, isAllTarget: false };
    case MagicType.WATER:
      return { attack: 5, tpCost: 10, isAllTarget: false };
    case MagicType.THUNDER:
      return { attack: 8, tpCost: 35, isAllTarget: true };
    default:
      throw new Error('不明な魔法タイプ');
  }
}

⚠️ MagicType.WINDが追加されても、switchコンパイルエラーにならず実行時まで気づけません

✅ OK: Record<Enum, T> のmapで型安全に

export enum MagicType {
  FIRE = 'fire',
  WATER = 'water',
  THUNDER = 'thunder',
}

export type Magic = {
  name: string;
  attack: number;
  tpCost: number;
  isAllTarget: boolean;
};

export const MAGIC_MAP: Record<MagicType, Magic> = {
  [MagicType.FIRE]: {
    name: '炎魔法',
    attack: 10,
    tpCost: 30,
    isAllTarget: false,
  },
  [MagicType.WATER]: {
    name: '水魔法',
    attack: 5,
    tpCost: 10,
    isAllTarget: false,
  },
  [MagicType.THUNDER]: {
    name: '雷魔法',
    attack: 8,
    tpCost: 35,
    isAllTarget: true,
  },
};

export function canUseMagic(type: MagicType, currentTp: number): boolean {
  const magic = MAGIC_MAP[type];
  return currentTp >= magic.tpCost;
}

MagicType.WINDをenumに追加すると…

export enum MagicType {
  FIRE = 'fire',
  WATER = 'water',
  THUNDER = 'thunder',
  WIND = 'wind', // ← 追加
}

// ✅ MAGIC_MAP側が即座に型エラーになるので、実装漏れを防げる!
export const MAGIC_MAP: Record<MagicType, Magic> = {
  // ... WINDが未定義なのでコンパイルエラー
};

ポイント: Record<MagicType, Magic> は「enumのすべてのキーに対して値が必要」という制約を型で表現します。追加漏れをコンパイル時に検知できるのが最大の強みです。


■ ③ ロジックが複雑な場合は「interface設計(ハンドラmap)」を使う

値の対応表だけでなく、処理そのもの(関数)をmapに入れるパターンです。GoFのStrategyパターンをTypeScriptで表現したイメージです。

export enum MagicType {
  FIRE = 'fire',
  WATER = 'water',
  THUNDER = 'thunder',
}

type MagicInput = {
  currentTp: number;
};

type MagicOutput = {
  attack: number;
  canUse: boolean;
  isAllTarget: boolean;
};

type MagicHandler = (input: MagicInput) => MagicOutput;

// 魔法ごとのハンドラを定義
const fireMagic: MagicHandler = ({ currentTp }) => ({
  attack: 10,
  canUse: currentTp >= 30,
  isAllTarget: false,
});

const waterMagic: MagicHandler = ({ currentTp }) => ({
  attack: 5,
  canUse: currentTp >= 10,
  isAllTarget: false,
});

const thunderMagic: MagicHandler = ({ currentTp }) => ({
  attack: 8,
  canUse: currentTp >= 35,
  isAllTarget: true,
});

// ハンドラをmapにまとめる
const MAGIC_MAP: Record<MagicType, MagicHandler> = {
  [MagicType.FIRE]: fireMagic,
  [MagicType.WATER]: waterMagic,
  [MagicType.THUNDER]: thunderMagic,
};

// 呼び出し口はシンプルに
export function executeMagic(type: MagicType, input: MagicInput): MagicOutput {
  return MAGIC_MAP[type](input);
}
console.log(executeMagic(MagicType.FIRE, { currentTp: 50 }));
// { attack: 10, canUse: true, isAllTarget: false }

console.log(executeMagic(MagicType.WATER, { currentTp: 5 }));
// { attack: 5, canUse: false, isAllTarget: false }

console.log(executeMagic(MagicType.THUNDER, { currentTp: 40 }));
// { attack: 8, canUse: true, isAllTarget: true }

ポイント: 呼び出し側は executeMagic(type, input) だけ知っていれば良く、各魔法の内部ロジックを知る必要がありません。新しい魔法を追加するときも、ハンドラ関数を書いてmapに追加するだけです。


■ ④ フラグ引数の使用は控えよう

booleanや数値を渡して関数の挙動を切り替えるパラメータを「フラグ引数」と呼びます。

❌ NG: フラグ引数

function calculatePrice(price: number, isPremium: boolean) {
  if (isPremium) {
    return price * 0.8;
  }
  return price;
}

// 呼び出し側だけ見ると、trueの意味がわからない
calculatePrice(100, true);

フラグ引数の3つの問題:

  1. 呼び出し側で意味が不明true の意味がわからない)
  2. 分岐が関数の中に隠れる(どんな処理がされるか外から判断できない)
  3. フラグが増えると爆発する(条件の組み合わせが指数的に増加)
// ❌ フラグが増えた地獄の例
function calculatePrice(
  price: number,
  isPremium: boolean,
  isCampaign: boolean,
  isFirstPurchase: boolean
) {
  if (isPremium) {
    if (isCampaign) {
      if (isFirstPurchase) {
        return price * 0.5;
      }
      return price * 0.7;
    }
    return price * 0.8;
  }
  return price;
}

✅ OK: 3つの改善パターン

パターン①:関数を分ける

function calculateNormalPrice(price: number) {
  return price;
}

function calculatePremiumPrice(price: number) {
  return price * 0.8;
}

calculateNormalPrice(100); // 100
calculatePremiumPrice(100); // 80

パターン②:enumで意味を持たせる

export enum UserType {
  NORMAL = 'normal',
  PREMIUM = 'premium',
}

function calculatePrice(price: number, userType: UserType) {
  if (userType === UserType.PREMIUM) {
    return price * 0.8;
  }
  return price;
}

calculatePrice(100, UserType.NORMAL); // 100
calculatePrice(100, UserType.PREMIUM); // 80

パターン③:mapで分岐を消す(最も推奨)

export enum UserType {
  NORMAL = 'normal',
  PREMIUM = 'premium',
}

type PriceCalculator = (price: number) => number;

const PRICE_MAP: Record<UserType, PriceCalculator> = {
  [UserType.NORMAL]: (price) => price,
  [UserType.PREMIUM]: (price) => price * 0.8,
};

function calculatePrice(price: number, userType: UserType) {
  return PRICE_MAP[userType](price);
}

calculatePrice(100, UserType.NORMAL); // 100
calculatePrice(100, UserType.PREMIUM); // 80

まとめ

アンチパターン 問題点 解決策
深いネスト 可読性の低下 早期リターン(ガード節)
switch ケース追加時の漏れ検知不可 Record<Enum, T>のmap
複雑なswitch/if ロジック分散 ハンドラmap(Strategyパターン)
フラグ引数 意図不明・組み合わせ爆発 関数分割 / enum / map

結局何をすればいいの❓

まずはこの順番で習得しましょう。

  1. 今すぐ: 既存コードのネストを早期リターンに書き直す
  2. 今週: switch文を Record<Enum, T> のmapに置き換えてみる
  3. 今月: ロジックが複雑な箇所にハンドラmapパターンを導入する
  4. 継続: 関数の引数を見直し、フラグ引数を排除する

Record<Enum, T> のmapパターンは最初は慣れが必要ですが、一度身につけると型の恩恵で安全なリファクタリングができるようになります。ぜひ練習問題も試してみてください!


練習問題

余裕がある方はぜひ取り組んでみてください。解答例も用意しているので、自分で書いた後に見比べてみましょう!


🟢 レベル1(関数定義)

問題1:税込価格の計算(税率10%)

// 入力
calculatePrice(100)

// 出力
// 110
解答例を見る
function calculatePrice(price: number): number {
  return price * 1.1;
}

console.log(calculatePrice(100)); // 110

ポイント: 税率をマジックナンバーにせず、定数化するとさらに良いです。

const TAX_RATE = 1.1;

function calculatePrice(price: number): number {
  return price * TAX_RATE;
}

問題2:送料の計算(5000円以上で無料)

calculateShipping(3000) // 500
calculateShipping(6000) // 0
解答例を見る
const FREE_SHIPPING_THRESHOLD = 5000;
const SHIPPING_FEE = 500;

function calculateShipping(price: number): number {
  return price >= FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_FEE;
}

console.log(calculateShipping(3000)); // 500
console.log(calculateShipping(6000)); // 0

ポイント: 閾値・送料をマジックナンバーにせず定数化しておくと、仕様変更時に1箇所直すだけで済みます。


🟡 レベル2(Enum + Map)

問題1:ユーザー種別ごとの割引適用

// NORMAL → 割引なし
// PREMIUM → 20%オフ

calculatePrice(100, UserType.PREMIUM) // 80
解答例を見る
export enum UserType {
  NORMAL = 'normal',
  PREMIUM = 'premium',
}

const DISCOUNT_RATE_MAP: Record<UserType, number> = {
  [UserType.NORMAL]: 1,
  [UserType.PREMIUM]: 0.8,
};

function calculatePrice(price: number, userType: UserType): number {
  return price * DISCOUNT_RATE_MAP[userType];
}

console.log(calculatePrice(100, UserType.NORMAL));   // 100
console.log(calculatePrice(100, UserType.PREMIUM));  // 80

ポイント: 割引率をmapで管理することで、新しいユーザー種別(例:VIP)が追加されたとき、mapに追加しなければ型エラーになります。追加漏れを防げます。


問題2:配送方法ごとの送料計算

// STANDARD → 500円
// EXPRESS  → 1000円
// SAME_DAY → 2000円

calculateShipping(ShippingType.EXPRESS) // 1000
解答例を見る
export enum ShippingType {
  STANDARD = 'standard',
  EXPRESS = 'express',
  SAME_DAY = 'same_day',
}

const SHIPPING_FEE_MAP: Record<ShippingType, number> = {
  [ShippingType.STANDARD]: 500,
  [ShippingType.EXPRESS]: 1000,
  [ShippingType.SAME_DAY]: 2000,
};

function calculateShipping(type: ShippingType): number {
  return SHIPPING_FEE_MAP[type];
}

console.log(calculateShipping(ShippingType.STANDARD)); // 500
console.log(calculateShipping(ShippingType.EXPRESS));  // 1000
console.log(calculateShipping(ShippingType.SAME_DAY)); // 2000

ポイント: 関数本体がmap参照の1行になります。ロジックがmapに集約されているため、送料の変更・種別の追加がどこを直せばいいか一目瞭然です。


🟠 レベル3(型 + Map + function)

問題1:ユーザー種別ごとの価格計算ロジック(price + shipping)

// NORMAL  → そのまま(送料500円)
// PREMIUM → 20%オフ(送料500円)
// VIP     → 30%オフ + 送料無料

calculatePrice(100, UserType.VIP)
// { price: 70, shipping: 0 }
解答例を見る
export enum UserType {
  NORMAL = 'normal',
  PREMIUM = 'premium',
  VIP = 'vip',
}

type PriceResult = {
  price: number;
  shipping: number;
};

type PriceCalculator = (price: number) => PriceResult;

const PRICE_CALCULATOR_MAP: Record<UserType, PriceCalculator> = {
  [UserType.NORMAL]: (price) => ({
    price,
    shipping: 500,
  }),
  [UserType.PREMIUM]: (price) => ({
    price: price * 0.8,
    shipping: 500,
  }),
  [UserType.VIP]: (price) => ({
    price: price * 0.7,
    shipping: 0,
  }),
};

function calculatePrice(price: number, userType: UserType): PriceResult {
  return PRICE_CALCULATOR_MAP[userType](price);
}

console.log(calculatePrice(100, UserType.NORMAL));   // { price: 100, shipping: 500 }
console.log(calculatePrice(100, UserType.PREMIUM));  // { price: 80,  shipping: 500 }
console.log(calculatePrice(100, UserType.VIP));      // { price: 70,  shipping: 0   }

ポイント: 戻り値を PriceResult という型でまとめることで、「価格と送料はセットで返す」という仕様が型として表現できています。各ユーザー種別のロジックがmapの中に閉じており、calculatePrice本体はどのユーザー種別かを知る必要がありません。


問題2:支払い方法ごとの手数料計算

// CREDIT_CARD   → 3%
// BANK_TRANSFER → 固定200円
// PAYPAY        → 1%

calculateFee(1000, PaymentType.CREDIT_CARD)   // 30
calculateFee(1000, PaymentType.BANK_TRANSFER) // 200
calculateFee(1000, PaymentType.PAYPAY)        // 10
解答例を見る
export enum PaymentType {
  CREDIT_CARD = 'credit_card',
  BANK_TRANSFER = 'bank_transfer',
  PAYPAY = 'paypay',
}

type FeeCalculator = (price: number) => number;

const FEE_CALCULATOR_MAP: Record<PaymentType, FeeCalculator> = {
  [PaymentType.CREDIT_CARD]: (price) => price * 0.03,
  [PaymentType.BANK_TRANSFER]: () => 200,        // 金額に関係なく固定
  [PaymentType.PAYPAY]: (price) => price * 0.01,
};

function calculateFee(price: number, paymentType: PaymentType): number {
  return FEE_CALCULATOR_MAP[paymentType](price);
}

console.log(calculateFee(1000, PaymentType.CREDIT_CARD));   // 30
console.log(calculateFee(1000, PaymentType.BANK_TRANSFER)); // 200
console.log(calculateFee(1000, PaymentType.PAYPAY));        // 10

ポイント: BANK_TRANSFER のように引数を使わない関数も、FeeCalculator 型((price: number) => number)に合わせることでmapに統一できます。呼び出し側は「支払い方法を渡す」だけでよく、計算の詳細を知る必要がありません。


🔴 レベル4(要件から設計)

問題1:注文の合計金額計算

仕様:

  • ユーザー種別:NORMAL / PREMIUM(10%オフ) / VIP(20%オフ)
  • 配送方法:STANDARD(無料) / EXPRESS(+500円)
  • 支払い方法:CREDIT(2%手数料) / BANK(300円手数料)
calculateTotal({
  price: 1000,
  userType: UserType.VIP,
  shippingType: ShippingType.EXPRESS,
  paymentType: PaymentType.CREDIT,
})
// 期待値:割引 + 送料 + 手数料が適用された合計金額
解答例を見る
export enum UserType {
  NORMAL = 'normal',
  PREMIUM = 'premium',
  VIP = 'vip',
}

export enum ShippingType {
  STANDARD = 'standard',
  EXPRESS = 'express',
}

export enum PaymentType {
  CREDIT = 'credit',
  BANK = 'bank',
}

type CalculateTotalInput = {
  price: number;
  userType: UserType;
  shippingType: ShippingType;
  paymentType: PaymentType;
};

type DiscountCalculator = (price: number) => number;
type ShippingCalculator = () => number;
type PaymentFeeCalculator = (subtotal: number) => number;

const DISCOUNT_MAP: Record<UserType, DiscountCalculator> = {
  [UserType.NORMAL]: (price) => price,
  [UserType.PREMIUM]: (price) => price * 0.9,
  [UserType.VIP]: (price) => price * 0.8,
};

const SHIPPING_MAP: Record<ShippingType, ShippingCalculator> = {
  [ShippingType.STANDARD]: () => 0,
  [ShippingType.EXPRESS]: () => 500,
};

const PAYMENT_FEE_MAP: Record<PaymentType, PaymentFeeCalculator> = {
  [PaymentType.CREDIT]: (subtotal) => subtotal * 0.02,
  [PaymentType.BANK]: () => 300,
};

function calculateTotal(input: CalculateTotalInput): number {
  const discountedPrice = DISCOUNT_MAP[input.userType](input.price);
  const shippingFee = SHIPPING_MAP[input.shippingType]();
  const subtotal = discountedPrice + shippingFee;
  const paymentFee = PAYMENT_FEE_MAP[input.paymentType](subtotal);

  return subtotal + paymentFee;
}

console.log(
  calculateTotal({
    price: 1000,
    userType: UserType.VIP,
    shippingType: ShippingType.EXPRESS,
    paymentType: PaymentType.CREDIT,
  })
);
// 計算過程:
//   割引後: 1000 * 0.8 = 800
//   送料後: 800 + 500 = 1300(小計)
//   手数料: 1300 * 0.02 = 26
//   合計:   1300 + 26 = 1326

ポイント: 「割引 → 送料 → 手数料」の計算順序を関数本体に明示し、それぞれの計算ロジックはmapに委譲しています。新しいユーザー種別・配送方法・支払い方法が増えても、calculateTotal本体を触る必要がありません。責務が明確に分離されています。


問題2:画像生成ジョブの実行戦略の切り替え

仕様:

  • IMMEDIATE → すぐに実行
  • SCHEDULED → キューに登録
  • STANDARD → 通常品質(priority: normal
  • PRO → 高品質(priority: high
executeJob({
  executionType: ExecutionType.IMMEDIATE,
  apiModel: ApiModel.PRO,
  stylingId: 42,
  shotIds: [1, 2, 3],
})
解答例を見る
export enum ExecutionType {
  IMMEDIATE = 'immediate',
  SCHEDULED = 'scheduled',
}

export enum ApiModel {
  STANDARD = 'standard',
  PRO = 'pro',
}

type ExecuteJobInput = {
  executionType: ExecutionType;
  apiModel: ApiModel;
  stylingId: number;
  shotIds: number[];
};

type ExecuteJobResult = {
  message: string;
  priority: 'normal' | 'high';
};

type JobExecutor = (input: ExecuteJobInput) => Promise<ExecuteJobResult>;

// APIモデルごとの優先度をmapで管理
const API_MODEL_PRIORITY_MAP: Record<ApiModel, ExecuteJobResult['priority']> = {
  [ApiModel.STANDARD]: 'normal',
  [ApiModel.PRO]: 'high',
};

// 実行戦略ごとのハンドラをmapで管理
const JOB_EXECUTOR_MAP: Record<ExecutionType, JobExecutor> = {
  [ExecutionType.IMMEDIATE]: async (input) => ({
    message: `すぐに画像生成を開始します。stylingId=${input.stylingId}`,
    priority: API_MODEL_PRIORITY_MAP[input.apiModel],
  }),

  [ExecutionType.SCHEDULED]: async (input) => ({
    message: `画像生成ジョブをキューに登録します。stylingId=${input.stylingId}`,
    priority: API_MODEL_PRIORITY_MAP[input.apiModel],
  }),
};

async function executeJob(input: ExecuteJobInput): Promise<ExecuteJobResult> {
  return JOB_EXECUTOR_MAP[input.executionType](input);
}

// 動作確認
(async () => {
  const result = await executeJob({
    executionType: ExecutionType.IMMEDIATE,
    apiModel: ApiModel.PRO,
    stylingId: 42,
    shotIds: [1, 2, 3],
  });
  console.log(result);
  // { message: 'すぐに画像生成を開始します。stylingId=42', priority: 'high' }
})();

ポイント: 2つの軸(executionTypeapiModel)を、それぞれ独立したmapで管理しています。もし BATCH(まとめて実行)という実行タイプが増えても、JOB_EXECUTOR_MAPにエントリを追加するだけです。ULTRA(最高品質)というモデルが増えても、API_MODEL_PRIORITY_MAPに追加するだけです。2つの軸が独立しているため、組み合わせが増えても関数本体は変わりません。


株式会社xincere

株式会社xincereでは、実務未経験のエンジニアの方や学生エンジニアインターンを採用し一緒に働いています。

※ シンシアにおける働き方の様子はこちら
https://www.wantedly.com/companies/xincere-inc/stories

シンシアでは、年間100人程度の実務未経験の方が応募し技術面接を受けます。
その経験を通し、実務未経験者の方にぜひ身につけて欲しい技術力(文法)をここでは紹介していきます。

17
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?