TypeScript | 関数・クラス・ジェネリクス:関数設計の深化 – 関数設計での型肥大対策

TypeScript TypeScript
スポンサーリンク

ゴール:「型がデカくなってきた…」と感じたときに、落ち着いて“細く・分けて・名前をつける”発想を持てるようにする

TypeScript を真面目に書けば書くほど、
そのうち必ずこうなります。

「型、でかすぎない?」
「この関数の型注釈、もはや呪文なんだけど?」

ここでやるのは、

  • 型が肥大化する“よくあるパターン”を知る
  • それをどう分解・抽出・名前付けすればスッキリするかを知る

という「整理術」です。

コードを“短くする”というより、
「意味ごとに分けて、読みやすくする」イメージで聞いてください。


まず「型が肥大している状態」を具体的に見てみる

例1:引数の型がその場でゴチャゴチャ書かれている

よくあるのが、こういう関数です。

function createUser(
  input: {
    id: number;
    name: string;
    email?: string;
    roles: { id: number; name: string }[];
    meta?: {
      createdAt: string;
      updatedAt?: string;
    };
  },
  options: {
    sendMail: boolean;
    log: boolean;
    tags?: string[];
  }
): {
  id: number;
  name: string;
  email: string | null;
  roles: { id: number; name: string }[];
} {
  // ...
}
TypeScript

動くし、型もちゃんとしている。
でも、読む側からすると「一瞬で理解するのは無理」です。

ここで起きているのは、

  • 「構造の定義」と「関数のロジック」が一箇所にベタッとくっついている
  • 「何を表しているか」が、型の“形”からしか読み取れない

という状態です。

例2:ジェネリクス+条件付き型がその場で展開されている

もう少し進んだ例。

function wrap<T extends { id: string | number }>(
  value: T,
  options?: {
    asArray?: boolean;
    withMeta?: boolean;
  }
): T extends any
  ? (typeof options) extends { asArray: true }
    ? (typeof options) extends { withMeta: true }
      ? { data: T[]; meta: { count: number } }
      : { data: T[] }
    : (typeof options) extends { withMeta: true }
      ? { data: T; meta: { count: number } }
      : { data: T }
  : never {
  // ...
}
TypeScript

「やりたいこと」は分かるけど、
「一発で理解する」のはほぼ不可能です。

ここで起きているのは、

  • 条件付き型を“その場で全部書いている”
  • 「パターンごとの意味」に名前が付いていない

という状態です。


対策1:「意味のある塊」に名前をつけて外に出す

入力・出力の型をまず外に出す

さっきの createUser を整理してみます。

まず、入力と出力に名前をつけます。

type CreateUserInput = {
  id: number;
  name: string;
  email?: string;
  roles: { id: number; name: string }[];
  meta?: {
    createdAt: string;
    updatedAt?: string;
  };
};

type CreateUserOptions = {
  sendMail: boolean;
  log: boolean;
  tags?: string[];
};

type CreatedUser = {
  id: number;
  name: string;
  email: string | null;
  roles: { id: number; name: string }[];
};
TypeScript

そして関数はこう書きます。

function createUser(
  input: CreateUserInput,
  options: CreateUserOptions
): CreatedUser {
  // ...
}
TypeScript

これだけで、関数の宣言部分はかなり読みやすくなります。

「この関数は何を受け取って、何を返すのか」が、
“型の形”ではなく“型の名前”から分かるようになります。

「型の名前」は“ドメインの言葉”でつける

CreateUserInputCreatedUser という名前は、
「この型が何のためのデータか」を表しています。

{ id: number; name: string; ... } という形だけ見ても、
それが「ユーザー作成の入力」なのか「DB から取ってきたユーザー」なのかは分かりません。

型が肥大してきたときは、

  • その塊は「何のためのデータ」なのか
  • それにふさわしい“名前”は何か

を考えて、「形」から「名前」へ情報を移すと、
関数の宣言が一気に軽くなります。


対策2:ジェネリクス+条件付き型は「型エイリアス」に逃がす

その場で書かずに「型レベルの関数」として切り出す

さっきの wrap の戻り値型を整理してみます。

やりたいことを日本語にすると、

  • asArray: true なら data は配列
  • withMeta: true なら meta を付ける

です。

これを型エイリアスに分けます。

type WrapOptions = {
  asArray?: boolean;
  withMeta?: boolean;
};

type WrapResult<T, O extends WrapOptions | undefined> =
  O extends { asArray: true }
    ? O extends { withMeta: true }
      ? { data: T[]; meta: { count: number } }
      : { data: T[] }
    : O extends { withMeta: true }
      ? { data: T; meta: { count: number } }
      : { data: T };
TypeScript

そして関数はこう書きます。

function wrap<T extends { id: string | number }, O extends WrapOptions | undefined>(
  value: T,
  options?: O
): WrapResult<T, O> {
  // ...
}
TypeScript

戻り値の型注釈が WrapResult<T, O> になっただけで、
関数の宣言はかなり読みやすくなります。

「細かい条件付き型のロジック」は WrapResult の中に閉じ込められ、
関数の宣言は「T と O から WrapResult を計算する」という
“型レベルの関数呼び出し”の形になります。

「型のロジック」と「値のロジック」を分離する

ここでやっているのは、

  • 型の世界の if(条件付き型)
  • 値の世界の if(実際の条件分岐)

を、別々の場所に分けることです。

型が肥大しているときは、
たいていこの 2 つが「同じ場所にベタッと書かれている」状態です。

それを、

  • 型のロジック → 型エイリアスや型レベルのユーティリティにまとめる
  • 値のロジック → 関数本体に残す

というふうに分けると、
どちらも読みやすくなります。


対策3:「1 関数で全部やる」をやめて、関数を分ける

戻り値のユニオンが肥大しているときは「関数を分ける」選択肢も持つ

例えば、こういう関数があります。

type UserResult =
  | { kind: "found"; user: User }
  | { kind: "notFound" }
  | { kind: "error"; error: string };

function getUser(id: number): UserResult {
  // ...
}
TypeScript

これはこれで悪くないのですが、
呼び出し側は毎回 kind を見て分岐する必要があります。

もし「よくあるパターン」が決まっているなら、
関数を分けてしまうのも手です。

function findUserOrThrow(id: number): User {
  const result = getUser(id);
  if (result.kind === "found") return result.user;
  if (result.kind === "notFound") {
    throw new Error("user not found");
  }
  throw new Error(result.error);
}

function tryGetUser(id: number): User | null {
  const result = getUser(id);
  if (result.kind === "found") return result.user;
  return null;
}
TypeScript

「全部入りの巨大な戻り値型」を 1 個だけ持つのではなく、
「用途ごとに“ちょうどいい戻り値型”を持つ関数」を複数用意する
という発想です。

型肥大の一部は、
「1 関数に責務を詰め込みすぎている」ことから来ています。

「汎用関数」と「用途特化関数」を分ける

getUser のような「汎用的な結果型」を返す関数は、
内部で使う“基礎 API”として残しておきます。

その上に、「用途特化の薄いラッパー関数」を重ねていく。

// 汎用
function getUser(id: number): UserResult { /* ... */ }

// 用途特化
function mustGetUser(id: number): User { /* ... */ }
function maybeGetUser(id: number): User | null { /* ... */ }
TypeScript

こうすると、

  • 型が一番複雑なのは getUser だけ
  • 呼び出し側は、用途に合った“細い型”の関数を選べる

という構造になります。

「型が太ってきたな」と感じたら、
「この関数、用途を分けて 2〜3 個にできないか?」
と一度考えてみてください。


対策4:型レベルの「中間型」を作る

「途中の型」に名前をつけるだけで読みやすくなる

例えば、こういう戻り値型があります。

function buildConfig(
  env: "dev" | "prod"
): {
  env: "dev" | "prod";
  db: {
    host: string;
    port: number;
  };
  featureFlags: {
    newUI: boolean;
    betaMode: boolean;
  };
} {
  // ...
}
TypeScript

これを少しだけ分解します。

type DbConfig = {
  host: string;
  port: number;
};

type FeatureFlags = {
  newUI: boolean;
  betaMode: boolean;
};

type AppConfig = {
  env: "dev" | "prod";
  db: DbConfig;
  featureFlags: FeatureFlags;
};

function buildConfig(env: "dev" | "prod"): AppConfig {
  // ...
}
TypeScript

やっていることは単純で、

  • 「DB の設定」
  • 「フラグの集合」
  • 「アプリ全体の設定」

という「中間の概念」に名前をつけただけです。

でもこれで、

  • 関数の宣言は AppConfig という一言で済む
  • 中身の構造を知りたくなったら、型定義を辿ればいい

という状態になります。

型が肥大してきたら、

「この中に“意味のある中間の塊”はないか?」
「それに名前をつけられないか?」

と探してみてください。


まとめ:関数設計での型肥大対策を自分の言葉で言うと

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

型が肥大して苦しくなるのは、

  • その場で全部書いている
  • 「意味のある塊」に名前が付いていない
  • 1 関数に責務を詰め込みすぎている

ときに起きる。

だから対策は、

  • 入力・出力の型に名前をつけて外に出す
  • ジェネリクス+条件付き型は型エイリアスに逃がす
  • 「全部入り関数」と「用途特化関数」を分ける
  • 中間の概念(Config, Result, Options など)に型として名前をつける

という「分ける・名前をつける・細くする」動きになる。

コードを書いていて、
「この型、さすがに太りすぎでは?」と感じたら、
一度手を止めて、

「これ、どんな“意味の塊”に分けられる?」
「どんな名前をつけたら読みやすくなる?」

と自分に聞いてみてください。

その一呼吸が、
あなたの TypeScript の型を
“ただ詳しいだけの巨大な型”から、
“意味ごとに整理された読みやすい型”
に変えていきます。

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