TypeScript関数オーバーロードの実務活用ガイド|型安全な実装パターン集

TypeScript

TypeScript関数オーバーロードの実務活用ガイド|型安全な実装パターン集

はじめに:関数オーバーロードとは

TypeScriptの関数オーバーロード(Function Overload)は、同じ関数名で異なる引数の型や個数に対応させるための機能です。静的型言語の特性を活かして、実行時エラーを防ぎながら柔軟な関数設計ができます。

実務では、単なる型定義の見た目を整えるだけでなく、開発チーム全体のコード品質向上と保守性改善に直結する重要なテクニックです。本記事では、教科書的な説明ではなく、実際のプロジェクトで遭遇する実務シーンを想定したパターンを紹介します。

簡易的な解説:オーバーロードの基本構造

関数オーバーロードは、複数のシグネチャ宣言と、それらを統合する実装関数で構成されます。

// シグネチャ1
function process(data: string): string;
// シグネチャ2
function process(data: number): number;
// 実装関数
function process(data: string | number): string | number {
  if (typeof data === 'string') {
    return data.toUpperCase();
  }
  return data * 2;
}

const result1 = process('hello'); // string型
const result2 = process(42); // number型

重要なポイントは、シグネチャは複数定義できますが、実装は1つだけということです。シグネチャはコンパイル時のチェック、実装は実際の処理ロジックを担当します。

業務でのユースケース:実務での登場シーン

ユースケース1:APIレスポンス処理

実務で最も遭遇しやすいのが、APIのレスポンス処理です。IDで単一オブジェクトを取得する場合と、複数オブジェクトを取得する場合で戻り値の型が異なります。

// 実際のプロジェクトでの例:ユーザー情報取得API
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

// シグネチャ:単一ユーザー取得
async function fetchUser(id: number): Promise;
// シグネチャ:複数ユーザー取得
async function fetchUser(ids: number[]): Promise;

// 実装
async function fetchUser(input: number | number[]): Promise {
  const baseUrl = 'https://api.example.com/users';
  
  if (Array.isArray(input)) {
    const params = new URLSearchParams({
      ids: input.join(',')
    });
    const response = await fetch(`${baseUrl}?${params}`);
    return response.json();
  } else {
    const response = await fetch(`${baseUrl}/${input}`);
    return response.json();
  }
}

// 使用側
const singleUser = await fetchUser(123); // User型
const multipleUsers = await fetchUser([123, 456]); // User[]型

ユースケース2:フォーム検証ユーティリティ

複数のバリデーションルールを持つ汎用的な検証関数も、オーバーロードの活躍シーンです。

interface ValidationResult {
  isValid: boolean;
  errors: string[];
}

// シグネチャ:単一フィールド検証
function validateForm(field: string, value: unknown): boolean;
// シグネチャ:全体検証
function validateForm(formData: Record): ValidationResult;

// 実装
function validateForm(
  fieldOrForm: string | Record,
  value?: unknown
): boolean | ValidationResult {
  // ルール定義
  const rules: Record boolean> = {
    email: (val) => typeof val === 'string' && /^[^@]+@[^@]+\.[^@]+$/.test(val),
    password: (val) => typeof val === 'string' && val.length >= 8,
    username: (val) => typeof val === 'string' && val.length >= 3,
    age: (val) => typeof val === 'number' && val >= 18 && val <= 120,
  };

  // 単一フィールド検証
  if (typeof fieldOrForm === 'string') {
    const validator = rules[fieldOrForm];
    if (!validator) {
      console.warn(`No validator for field: ${fieldOrForm}`);
      return true;
    }
    return validator(value);
  }

  // 全体検証
  const errors: string[] = [];
  const formData = fieldOrForm;

  Object.entries(formData).forEach(([field, val]) => {
    const validator = rules[field];
    if (validator && !validator(val)) {
      errors.push(`${field} is invalid`);
    }
  });

  return {
    isValid: errors.length === 0,
    errors,
  };
}

// 使用例
const emailValid = validateForm('email', 'user@example.com'); // boolean
const formValid = validateForm({
  email: 'user@example.com',
  password: 'securePass123',
  username: 'johndoe',
  age: 25,
}); // ValidationResult

実装コード:実務での複雑なパターン

キャッシュシステムの実装

データベースやAPIをキャッシュする際、取得と削除で異なるシグネチャを持つことが多いです。

class CacheManager {
  private cache = new Map();
  private ttl: number = 3600000; // 1時間

  // シグネチャ:キー指定で取得
  get(key: string): T | null;
  // シグネチャ:キー指定でデフォルト値付き取得
  get(key: string, defaultValue: T): T;
  // シグネチャ:複数キーで一括取得
  get(keys: string[]): Map;

  // 実装
  get(keyOrKeys: string | string[], defaultValue?: T): T | null | Map {
    const now = Date.now();

    // 複数キーでの取得
    if (Array.isArray(keyOrKeys)) {
      const result = new Map();
      keyOrKeys.forEach((key) => {
        const cached = this.cache.get(key);
        if (cached && cached.expiresAt > now) {
          result.set(key, cached.data);
        }
      });
      return result;
    }

    // 単一キーでの取得
    const cached = this.cache.get(keyOrKeys);
    
    if (!cached || cached.expiresAt <= now) {
      this.cache.delete(keyOrKeys);
      return defaultValue !== undefined ? defaultValue : null;
    }

    return cached.data;
  }

  // シグネチャ:単一キーで削除
  delete(key: string): void;
  // シグネチャ:複数キーで削除
  delete(keys: string[]): void;
  // シグネチャ:全削除
  delete(all: 'all'): void;

  // 実装
  delete(input: string | string[] | 'all'): void {
    if (input === 'all') {
      this.cache.clear();
      return;
    }

    if (Array.isArray(input)) {
      input.forEach((key) => this.cache.delete(key));
      return;
    }

    this.cache.delete(input);
  }

  set(key: string, data: T): void {
    this.cache.set(key, {
      data,
      expiresAt: Date.now() + this.ttl,
    });
  }
}

// 実装例
interface Product {
  id: number;
  name: string;
  price: number;
}

const productCache = new CacheManager();

productCache.set('prod_1', { id: 1, name: 'Laptop', price: 1000 });
productCache.set('prod_2', { id: 2, name: 'Mouse', price: 25 });

// 単一取得
const product = productCache.get('prod_1'); // Product | null
const fallback = productCache.get('prod_3', { id: 0, name: 'Unknown', price: 0 }); // Product

// 複数取得
const products = productCache.get(['prod_1', 'prod_2']); // Map

// 削除
productCache.delete('prod_1');
productCache.delete(['prod_1', 'prod_2']);
productCache.delete('all');

ロギングシステムの実装

ログレベルや出力形式によって異なるシグネチャを持つロガーも実務では頻出です。

type LogLevel = 'info' | 'warn' | 'error' | 'debug';

interface LogEntry {
  timestamp: string;
  level: LogLevel;
  message: string;
  context?: Record;
}

class Logger {
  private logs: LogEntry[] = [];
  private maxLogs = 1000;

  // シグネチャ:単純なメッセージログ
  log(level: LogLevel, message: string): void;
  // シグネチャ:コンテキスト付きログ
  log(level: LogLevel, message: string, context: Record): void;
  // シグネチャ:エラーオブジェクト付きログ
  log(level: LogLevel, message: string, error: Error): void;

  // 実装
  log(
    level: LogLevel,
    message: string,
    contextOrError?: Record | Error
  ): void {
    let context: Record | undefined;

    if (contextOrError instanceof Error) {
      context = {
        errorName: contextOrError.name,
        errorMessage: contextOrError.message,
        stack: contextOrError.stack,
      };
    } else if (typeof contextOrError === 'object') {
      context = contextOrError;
    }

    const entry: LogEntry = {
      timestamp: new Date().toISOString(),
      level,
      message,
      context,
    };

    this.logs.push(entry);

    // ログローテーション
    if (this.logs.length > this.maxLogs) {
      this.logs = this.logs.slice(-this.maxLogs);
    }

    // コンソール出力
    this.printLog(entry);
  }

  private printLog(entry: LogEntry): void {
    const prefix = `[${entry.timestamp}] [${entry.level.toUpperCase()}]`;
    const message = `${prefix} ${entry.message}`;

    switch (entry.level) {
      case 'error':
        console.error(message, entry.context || '');
        break;
      case 'warn':
        console.warn(message, entry.context || '');
        break;
      default:
        console.log(message, entry.context || '');
    }
  }

  // シグネチャ:全ログ取得
  getLogs(): LogEntry[];
  // シグネチャ:レベル指定で取得
  getLogs(level: LogLevel): LogEntry[];
  // シグネチャ:数量指定で取得
  getLogs(count: number): LogEntry[];

  // 実装
  getLogs(filterOrCount?: LogLevel | number): LogEntry[] {
    if (typeof filterOrCount === 'string') {
      return this.logs.filter((log) => log.level === filterOrCount);
    }

    if (typeof filterOrCount === 'number') {
      return this.logs.slice(-filterOrCount);
    }

    return this.logs;
  }
}

// 使用例
const logger = new Logger();

logger.log('info', 'Application started');
logger.log('warn', 'Deprecated API used', { apiName: '/v1/users' });

try {
  throw new Error('Database connection failed');
} catch (err) {
  if (err instanceof Error) {
    logger.log('error', 'Critical error occurred', err);
  }
}

const allLogs = logger.getLogs(); // LogEntry[]
const errors = logger.getLogs('error'); // LogEntry[]
const lastTen = logger.getLogs(10); // LogEntry[]

よくある応用パターン

パターン1:条件付き戻り値型

入力に基づいて戻り値の型が自動的に推論されるパターンは、型安全性と使いやすさを両立させます。

// シグネチャ
function transform(
  value: unknown,
  type: T
): T extends 'string' ? string : T extends 'number' ? number : boolean;

// 実装
function transform(value: unknown, type: string): unknown {
  switch (type) {
    case 'string':
      return String(value);
    case 'number':
      return Number(value);
    case 'boolean':
      return Boolean(value);
    default:
      throw new Error(`Unknown type: ${type}`);
  }
}

// 使用例
const strResult = transform('42', 'string'); // string型
const numResult = transform('42', 'number'); // number型
const boolResult = transform('1', 'boolean'); // boolean型

パターン2:オプショナルなコールバック

非同期処理で、コールバック関数の有無で挙動を切り替えるパターンも実務で多用されます。

// シグネチャ:コールバックなし(Promise返却)
function fetchData(url: string): Promise;
// シグネチャ:コールバック付き(void返却)
function fetchData(
  url: string,
  callback: (error: Error | null, data?: unknown) => void
): void;

// 実装
function fetchData(
  url: string,
  callback?: (error: Error | null, data?: unknown) => void
): Promise | void {
  if (callback) {
    // コールバックモード
    fetch(url)
      .then((res) => res.json())
      .then((data) => callback(null, data))
      .catch((err) => callback(err));
    return;
  }

  // Promise モード
  return fetch(url).then((res) => res.json());
}

// 使用例
// Promise モード
const data1 = await fetchData('/api/users');

// コールバック モード
fetchData('/api/users', (error, data) => {
  if (error) {
    console.error('Failed:', error);
  } else {
    console.log('Success:', data);
  }
});

パターン3:ビルダーパターンとのハイブリッド

設定オブジェクトの構築で、オーバーロードはメソッドチェーンの型推論向上に役立ちます。

interface QueryOptions {
  where?: Record;
  select?: string[];
  limit?: number;
  offset?: number;
  orderBy?: string;
}

class QueryBuilder {
  private options: QueryOptions = {};

  // シグネチャ:条件追加
  where(field: string, value: unknown): this;
  // シグネチャ:複数条件追加
  where(conditions: Record): this;

  // 実装
  where(
    fieldOrConditions: string | Record,
    value?: unknown
  ): this {
    if (typeof fieldOrConditions === 'string') {
      this.options.where = this.options.where || {};
      this.options.where[fieldOrConditions] = value;
    } else {
      this.options.where = { ...this.options.where, ...fieldOrConditions };
    }
    return this;
  }

  // シグネチャ:複数カラム選択
  select(...columns: string[]): this;
  // シグネチャ:カラム配列選択
  select(columns: string[]): this;

  // 実装
  select(columnsOrFirst: string | string[], ...rest: string[]): this {
    const columns = Array.isArray(columnsOrFirst)
      ? columnsOrFirst
      : [columnsOrFirst, ...rest];
    this.options.select = columns;
    return this;
  }

  limit(count: number): this {
    this.options.limit = count;
    return this;
  }

  offset(count: number): this {
    this.options.offset = count;
    return this;
  }

  build(): QueryOptions {
    return this.options;
  }
}

// 使用例
const query = new QueryBuilder()
  .where('status', 'active')
  .select('id', 'name', 'email')
  .limit(10)
  .offset(20)
  .build();

const query2 = new QueryBuilder()
  .where({ status: 'active', role: 'admin' })
  .select(['id', 'name'])
  .build();

注意点:実務で気をつけるべきこと

注意1:シグネチャと実装の整合性

シグネチャで型チェックされる内容と、実装の実際の処理がズレていると、バグの温床になります。

// ❌ 悪い例:シグネチャと実装がズレている
function process(value: string): string; // 文字列を返すと宣言
function process(value: number): number;
function process(value: string | number): string | number {
  // しかし常にnumberを返している
  return 42; // これはコンパイラに検出されない場合がある
}

// ✅ 良い例:型ガード関数で厳密にチェック
function process(value: string): string;
function process(value: number): number;
function process(value: string | number): string | number {
  if (typeof value === 'string') {
    return value.toUpperCase(); // 確実にstring
  }
  if (typeof value === 'number') {
    return value * 2; // 確実にnumber
  }
  throw new Error('Unexpected type');
}

注意2:シグネチャの順序

より具体的なシグネチャを先に記述することが重要です。そうしないと、意図しないシグネチャにマッチしてしまいます。

// ❌ 悪い例:汎用的なシグネチャが先
function getValue(input: string | number | boolean): unknown;
function getValue(input: string): string; // これは到達しない

// ✅ 良い例:具体的なシグネチャが先
function getValue(input: string): string;
function getValue(input: number): number;
function getValue(input: boolean): boolean;
function getValue(input: string | number | boolean): string | number | boolean {
  return input;
}

注意3:過度な複雑化を避ける

シグネチャが多すぎたり、複雑すぎたりすると、メンテナンスが困難になります。5個以上のシグネチャがある場合は、設計の見直しを検討しましょう。

// ❌ 複雑すぎる
function complex(a: string | number, b?: number, c?: boolean, d?: Record): unknown;
function complex(a: string[], b?: string): unknown;
function complex(a: Map, b?: (val: unknown) => void): unknown;
// ... さらにシグネチャが続く

// ✅ 機能を分割した方が読みやすい
class DataProcessor {
  processString(value: string): string { /* ... */ }
  processArray(value: string[]): string[] { /* ... */ }
  processMap(value: Map): unknown { /* ... */ }
}

注意4:Pythonとの互換性を考慮

TypeScriptプロジェクトでも、Pythonのバックエンドと連携する場合は、オーバーロード情報がAPIドキュメントに正しく反映されていることを確認しましょう。

# Python側で対応するコード例
from typing import Union, List, overload
from typing_extensions import Literal

# Python 3.11+の@overload装飾子
@overload
def fetch_user(user_id: int) -> dict: ...

@overload
def fetch_user(user_ids: List[int]) -> List[dict]: ...

def fetch_user(input_param: Union[int, List[int]]) -> Union[dict, List[dict]]:
    if isinstance(input_param, list):
        return [{'id': uid} for uid in input_param]
    return {'id': input_param}

実装チェックリスト

オーバーロード実装時に確認すべき項目をまとめました。

  • ✅ シグネチャと実装の型情報が一致しているか
  • ✅ より具体的なシグネチャが先に配置されているか
  • ✅ 実装内で全シグネチャのケースに対応しているか
  • ✅ TypeScript strictモードで型エラーが出ていないか
  • ✅ エディタのインテリセンスが正確に候補を表示するか
  • ✅ 同僚がコードを読んで理解できるシンプルさか
  • ✅ ドキュメント化されているか(JSDocコメント)
  • ✅ ユニットテストで各シグネチャのケースをカバーしているか

まとめ

TypeScriptの関数オーバーロードは、単なる型定義機能ではなく、実務のコード品質向上に大きく貢献するツールです。

本記事で紹介した通り、APIレスポンス処理、フォーム検証、キャッシュシステム、ロギングなど、実際のプロジェクトには無数のユースケースが存在します。これらの場面で適切にオーバーロードを活用することで、以下のメリットが得られます。

  • 開発時に型チェッカーが間違った使い方を検出してくれる
  • IDEのインテリセンスが正確になり、開発速度が向上する
  • ランタイムエラーを未然に防ぎやすくなる
  • 後続の保守者がコードの意図を理解しやすくなる
  • チーム全体の型安全に対する意識が高まる

最初は複雑に思えるかもしれませんが、実務で使い続けるうちに、自然とベストプラクティスが身につきます。過度な複雑化を避け、チームの共通理解を基準に、適切なレベルでオーバーロードを活用してください。

正しく実装されたオーバーロードは、あなたのコードをより堅牢で、より読みやすく、そしてより保守性の高いものへと進化させる強力な武器になります。

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