ゴール:「型がデカくなってきた…」と感じたときに、落ち着いて“細く・分けて・名前をつける”発想を持てるようにする
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これだけで、関数の宣言部分はかなり読みやすくなります。
「この関数は何を受け取って、何を返すのか」が、
“型の形”ではなく“型の名前”から分かるようになります。
「型の名前」は“ドメインの言葉”でつける
CreateUserInput や CreatedUser という名前は、
「この型が何のためのデータか」を表しています。
{ 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 の型を
“ただ詳しいだけの巨大な型”から、
“意味ごとに整理された読みやすい型” に変えていきます。
