TypeScript | 関数・クラス・ジェネリクス:関数設計の深化 – 実務での関数分割ルール

TypeScript TypeScript
スポンサーリンク

ゴール:「どこまでを1つの関数にして、どこから分けるか」を自分の基準で判断できるようになる

実務で一番モヤっとしやすいのがここです。

「この関数、長い気はするけど、どこで分ければいいの?」
「分けたら分けたで、逆に追いづらくならない?」

ここでは、“現場で本当に使える”関数分割ルールを、
TypeScript の関数・型設計と絡めながら、具体例付きで整理していきます。

「絶対こうしろ」という教科書ルールではなく、
あなたが自分で判断するときの“物差し”を増やすイメージで読んでください。


ルール1:「1つの関数は“1つの目的”だけにする」

目的が2つ以上混ざっていたら、分割候補

まず、一番シンプルで強力なルールです。

「この関数は、何をしている関数?」と聞かれて、
“そして”が出てきたら分割候補。

例を見てみます。

async function createUserAndSendMail(input: CreateUserInput): Promise<void> {
  // 1. ユーザーをDBに保存
  const user = await userRepository.save(input);

  // 2. ログ出力
  logger.info("user created", { userId: user.id });

  // 3. メール送信
  await mailer.sendWelcomeMail(user.email);
}
TypeScript

やっていることは明らかに3つあります。

  • ユーザー作成
  • ログ
  • メール送信

これを分割すると、こうなります。

async function createUser(input: CreateUserInput): Promise<User> {
  const user = await userRepository.save(input);
  logger.info("user created", { userId: user.id });
  return user;
}

async function sendWelcomeMailToUser(user: User): Promise<void> {
  await mailer.sendWelcomeMail(user.email);
}

async function createUserAndSendMail(input: CreateUserInput): Promise<void> {
  const user = await createUser(input);
  await sendWelcomeMailToUser(user);
}
TypeScript

ここでのポイントは、

  • 「小さい関数」は“動詞+対象”で名前がつけやすい
  • 大きい関数は「小さい関数を組み合わせる“シナリオ”」として読める

ようになっていることです。

「この関数、説明すると“〜して、〜して、〜する”って言っちゃうな」と思ったら、
その“そして”のところで分割できないか考えてみてください。


ルール2:「分岐ごとに“意味のある名前”が付けられるなら関数にする」

if / switch の中身が長くなってきたら要注意

例えば、こんなコードがあります。

function handleEvent(event: Event) {
  if (event.type === "user_created") {
    // ユーザー作成時の処理がズラズラ…
  } else if (event.type === "user_deleted") {
    // ユーザー削除時の処理がズラズラ…
  } else if (event.type === "password_reset") {
    // パスワードリセット時の処理がズラズラ…
  }
}
TypeScript

このまま育てていくと、
handleEvent が「なんでも屋」になって破裂します。

ここで効くのが、

「この分岐の中身に、関数名をつけるとしたら?」

と自分に聞いてみることです。

function handleUserCreated(event: UserCreatedEvent) { /* ... */ }
function handleUserDeleted(event: UserDeletedEvent) { /* ... */ }
function handlePasswordReset(event: PasswordResetEvent) { /* ... */ }
TypeScript

こういう名前が自然に浮かぶなら、
それはもう関数にしてしまっていいサインです。

function handleEvent(event: Event) {
  switch (event.type) {
    case "user_created":
      return handleUserCreated(event);
    case "user_deleted":
      return handleUserDeleted(event);
    case "password_reset":
      return handlePasswordReset(event);
  }
}
TypeScript

TypeScript 的にも、

  • Event を判別可能ユニオンにしておく
  • 各ハンドラの引数をそれぞれの型にする

と、型の絞り込みもきれいに効きます。

type UserCreatedEvent = { type: "user_created"; user: User };
type UserDeletedEvent = { type: "user_deleted"; userId: number };
type PasswordResetEvent = { type: "password_reset"; email: string };

type Event = UserCreatedEvent | UserDeletedEvent | PasswordResetEvent;
TypeScript

分岐の中身が長くなってきたら、

「このブロックに“〜を処理する”という名前をつけられるか?」

を基準に、関数分割を検討してみてください。


ルール3:「データ変換」と「副作用」を分ける

変換ロジックは“純粋な関数”に閉じ込める

実務コードでよくあるのが、
「API レスポンスを変換して、そのまま画面に反映する」ような関数です。

async function loadAndRenderUser(id: number): Promise<void> {
  const res = await api.get(`/users/${id}`);

  const viewModel = {
    id: res.data.id,
    displayName: `${res.data.lastName} ${res.data.firstName}`,
    email: res.data.email ?? "未登録",
  };

  renderUser(viewModel);
}
TypeScript

ここには2種類のことが混ざっています。

  • データ変換(APIレスポンス → ViewModel)
  • 副作用(API呼び出し、画面描画)

変換部分だけを関数に切り出します。

type UserResponse = {
  id: number;
  firstName: string;
  lastName: string;
  email?: string;
};

type UserViewModel = {
  id: number;
  displayName: string;
  email: string;
};

function toUserViewModel(res: UserResponse): UserViewModel {
  return {
    id: res.id,
    displayName: `${res.lastName} ${res.firstName}`,
    email: res.email ?? "未登録",
  };
}

async function loadAndRenderUser(id: number): Promise<void> {
  const res = await api.get<UserResponse>(`/users/${id}`);
  const viewModel = toUserViewModel(res.data);
  renderUser(viewModel);
}
TypeScript

こうすると、

  • toUserViewModel は「純粋な変換関数」としてテストしやすい
  • loadAndRenderUser は「シナリオ(流れ)」だけを担当する

という役割分担になります。

関数の中に、

  • データ変換
  • ログ出力
  • ネットワーク呼び出し
  • DOM 操作

などが混ざり始めたら、
「変換だけをする小さい関数」に分けられないかを考えてみてください。


ルール4:「型が太り始めたら、関数を分けるサイン」

引数や戻り値の型が複雑になってきたら要注意

例えば、こんな関数があります。

function processUser(
  user: {
    id: number;
    name: string;
    email?: string;
    roles: { id: number; name: string }[];
  },
  options?: {
    sendMail?: boolean;
    log?: boolean;
    asAdmin?: boolean;
  }
): {
  success: boolean;
  error?: string;
  updatedUser?: {
    id: number;
    name: string;
    email?: string;
    roles: { id: number; name: string }[];
  };
} {
  // ...
}
TypeScript

型がすでに悲鳴を上げています。

ここでやるべきことは2段階です。

1つ目は、型に名前をつけて外に出すこと。

type User = {
  id: number;
  name: string;
  email?: string;
  roles: { id: number; name: string }[];
};

type ProcessUserOptions = {
  sendMail?: boolean;
  log?: boolean;
  asAdmin?: boolean;
};

type ProcessUserResult =
  | { success: true; updatedUser: User }
  | { success: false; error: string };
TypeScript

2つ目は、「この関数、責務を分けられないか?」を考えることです。

例えば、

  • ユーザーの更新ロジック
  • メール送信
  • ログ出力

を分けてしまう。

function updateUser(user: User, options: ProcessUserOptions): ProcessUserResult {
  // ユーザーの更新だけ
}

function maybeSendMail(user: User, options: ProcessUserOptions): void {
  // sendMail オプションを見てメール送信
}

function maybeLog(user: User, options: ProcessUserOptions): void {
  // log オプションを見てログ出力
}

function processUser(user: User, options: ProcessUserOptions): ProcessUserResult {
  const result = updateUser(user, options);

  if (!result.success) return result;

  maybeSendMail(result.updatedUser, options);
  maybeLog(result.updatedUser, options);

  return result;
}
TypeScript

「型が太ってきた」というのは、
「この関数に情報と責務を詰め込みすぎている」サインです。

型を整理しながら、
「この関数、本当は何個の役割を持っている?」と問い直してみてください。


ルール5:「テストを書こうとしたときに“つらい”なら分ける」

テストしづらさは、関数分割の強いシグナル

例えば、こんな関数をテストしようとします。

async function registerUser(input: RegisterInput): Promise<void> {
  const user = await userRepository.save(input);
  await mailer.sendWelcomeMail(user.email);
  logger.info("user registered", { userId: user.id });
}
TypeScript

これをテストしようとすると、

  • DB をモックして…
  • メール送信をモックして…
  • ロガーもモックして…

と、準備が大変になります。

ここでの違和感は、

「1つの関数をテストするために、モックしなきゃいけないものが多すぎる」

というところにあります。

関数を分けてみます。

async function saveUser(input: RegisterInput): Promise<User> {
  const user = await userRepository.save(input);
  logger.info("user registered", { userId: user.id });
  return user;
}

async function sendWelcomeMail(user: User): Promise<void> {
  await mailer.sendWelcomeMail(user.email);
}

async function registerUser(input: RegisterInput): Promise<void> {
  const user = await saveUser(input);
  await sendWelcomeMail(user);
}
TypeScript

これなら、

  • saveUser のテストでは「DB とログ」だけを意識すればいい
  • sendWelcomeMail のテストでは「メール送信」だけを意識すればいい
  • registerUser は「2つの関数が呼ばれる順番」をテストすればいい

というふうに、テストの焦点が分かれます。

「この関数、テストを書こうとしたときに、モックが3つ以上必要になる」
と感じたら、それは分割を検討する強いサインです。


まとめ:実務での関数分割ルールを自分の言葉で言うと

最後に、あなた自身の言葉でこう整理してみてください。

関数を分割するかどうか迷ったら、次を順番に自問する。

  1. この関数を説明するとき、「〜して、〜して、〜する」と“そして”が出てこないか
  2. if / switch の各ブロックに「〜を処理する」という名前をつけられないか
  3. データ変換と副作用が混ざっていないか(変換だけの関数にできないか)
  4. 引数・戻り値の型が太りすぎていないか(型を整理したら責務の多さが見えないか)
  5. テストを書こうとしたとき、モックが多すぎてつらくないか

どれか1つでも「うん、そうかも」と思ったら、
その関数は“分割候補”です。

大事なのは、「短い関数を量産すること」ではなく、

「1つの関数に“1つの役割”だけを持たせる」

という感覚を、少しずつ身体に染み込ませていくことです。

今書いている関数を1つ選んで、
上の5つを当てはめてみませんか。
どこを切り出せそうか、一緒に言語化してみてもいいですよ。

タイトルとURLをコピーしました