TypeScript 5.8でAPIクライアント層を全面改修したら、本番バグが激減した

3年半運用してきたプロジェクトのAPIクライアント設計を TypeScript 5.8 で書き直したら、型エラーで防げるバグが劇的に増えた。実務で効いた知見を共有します。

TypeScript 5.8で型安全なAPIクライアント設計を実装したら、本番バグが激減した話

先日、3年半運用してきたTypeScriptプロジェクトのAPIクライアント層を全面的に書き直す機会があった。きっかけは「バックエンドのレスポンス型が変わったのにフロントが気づかなかった」という典型的な事故。anyが1箇所紛れ込んでいて、コンパイルは通ったのに本番で壊れた。あれは地味につらかった。

その後、TypeScript 5.8で追加された機能をフル活用して設計し直したら、型エラーで防げる事故が劇的に増えて、開発速度的にも体感で2倍くらい変わった気がしている。正直まだ全部の機能を完全に把握しきれてないけど、実務で「これは効いた」と思った知見を共有したい。

なお、テスト戦略についてはJest・Vitest・Playwrightの使い分けの記事でも触れているので、組み合わせて読むと参考になるかもしれない。

TypeScript 5.8で何が変わったのか

2026年現在、TypeScriptは5.8が安定版として広く使われている。5.7から続くパフォーマンス改善に加えて、型推論まわりの細かい強化がかなり実務に効いてくる。

// 5.8で強化されたinferの使い方
type ExtractRouteParams<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ExtractRouteParams<`/${Rest}`>
    : T extends `${infer _Start}:${infer Param}`
    ? Param
    : never;

// 実際に使うとこんな感じ
type UserRouteParams = ExtractRouteParams<'/users/:userId/posts/:postId'>;
// => 'userId' | 'postId'

このテンプレートリテラル型の推論精度が5.8でかなり上がった。以前は再帰的なパターンで型推論が破綻することがあったのが、かなり安定して動くようになった印象だ。

// satisfies演算子との組み合わせが強力
const API_ROUTES = {
  users: {
    list: '/api/users',
    detail: '/api/users/:id',
    create: '/api/users',
  },
  posts: {
    list: '/api/posts',
    detail: '/api/posts/:id',
  },
} satisfies Record<string, Record<string, string>>;

// 型を保持したまま、型チェックもかかる
const detailRoute = API_ROUTES.users.detail; // string型として扱われるが存在チェックが効く

satisfies演算子、最初は「型アサーションとどう違うの?」と思ってたけど、使い込むと手放せなくなる。値を型チェックしつつ、その値の字面を保持したまま変数に代入できるからね。型アサーションだと型情報が失われて、後々の使用箇所で補完が効かなくなるんだけど、satisfiesなら大丈夫。

型安全なAPIクライアントの実装

ここが今回の本題。うちのチームで採用したパターンを紹介する。

// レスポンス型の定義
type ApiResponse<T> =
  | { success: true; data: T; timestamp: string }
  | { success: false; error: ApiError; statusCode: number };

type ApiError = {
  code: string;
  message: string;
  details?: Record<string, unknown>;
};

// エンドポイントの型定義
type EndpointDefinition<
  TPath extends string,
  TMethod extends HttpMethod,
  TRequest,
  TResponse
> = {
  path: TPath;
  method: TMethod;
  request: TRequest;
  response: TResponse;
};

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

// 実際のエンドポイント定義
type UserEndpoints = {
  getUser: EndpointDefinition<
    '/api/users/:id',
    'GET',
    { id: string },
    { id: string; name: string; email: string; createdAt: string }
  >;
  createUser: EndpointDefinition<
    '/api/users',
    'POST',
    { name: string; email: string },
    { id: string; name: string; email: string }
  >;
};

この型定義を基にして、実際のクライアントを実装する。

// パスパラメータを型安全に置換するユーティリティ
type FillPathParams<
  TPath extends string,
  TParams extends Record<string, string>
> = TPath extends `${infer Before}:${infer Param}/${infer After}`
  ? Param extends keyof TParams
    ? `${Before}${TParams[Param]}/${FillPathParams<After, TParams>}`
    : never
  : TPath extends `${infer Before}:${infer Param}`
  ? Param extends keyof TParams
    ? `${Before}${TParams[Param]}`
    : never
  : TPath;

// 型安全なAPIクライアントクラス
class TypedApiClient {
  private baseUrl: string;
  private headers: Record<string, string>;

  constructor(config: { baseUrl: string; headers?: Record<string, string> }) {
    this.baseUrl = config.baseUrl;
    this.headers = config.headers ?? {};
  }

  async request<TRequest, TResponse>(
    definition: EndpointDefinition<string, HttpMethod, TRequest, TResponse>,
    data: TRequest
  ): Promise<ApiResponse<TResponse>> {
    // パスパラメータを自動で埋める処理
    const path = this.fillPathParams(definition.path, data as Record<string, string>);
    
    try {
      const response = await fetch(`${this.baseUrl}${path}`, {
        method: definition.method,
        headers: {
          'Content-Type': 'application/json',
          ...this.headers,
        },
        body: definition.method !== 'GET'
          ? JSON.stringify(this.extractBodyParams(data as Record<string, unknown>, definition.path))
          : undefined,
      });

      const json = await response.json();
      
      if (!response.ok) {
        return {
          success: false,
          error: json.error ?? { code: 'UNKNOWN', message: 'Unknown error' },
          statusCode: response.status,
        };
      }

      return { success: true, data: json as TResponse, timestamp: new Date().toISOString() };
    } catch (error) {
      return {
        success: false,
        error: { code: 'NETWORK_ERROR', message: String(error) },
        statusCode: 0,
      };
    }
  }

  private fillPathParams(path: string, params: Record<string, string>): string {
    return path.replace(/:([^/]+)/g, (_, key) => params[key] ?? `:${key}`);
  }

  private extractBodyParams(
    data: Record<string, unknown>,
    path: string
  ): Record<string, unknown> {
    const pathParams = new Set(
      [...path.matchAll(/:([^/]+)/g)].map(([, key]) => key)
    );
    return Object.fromEntries(
      Object.entries(data).filter(([key]) => !pathParams.has(key))
    );
  }
}

// 使い方
const client = new TypedApiClient({ baseUrl: 'https://api.example.com' });
const endpoints: UserEndpoints = {} as UserEndpoints; // 実際は別ファイルで定義

// 型チェックが効く!
const result = await client.request(endpoints.getUser, { id: '123' });
if (result.success) {
  console.log(result.data.name); // 型補完が効く
} else {
  console.error(result.error.code);
}

このパターン、最初は「ちょっとやりすぎじゃない?」って思ってたけど、実際に使い始めたらレビューでの型エラー指摘がほぼゼロになった。地味に便利だ。

discriminated unionで状態管理を型安全に

APIクライアントの型安全性だけじゃなくて、フロントエンドの状態管理でも型を活用しているので紹介したい。

// フェッチ状態の型定義
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T; lastFetched: Date }
  | { status: 'error'; error: ApiError; retryCount: number };

// カスタムフック(React 19前提)
function useApiQuery<T>(
  fetcher: () => Promise<ApiResponse<T>>,
  options: { enabled?: boolean; maxRetries?: number } = {}
): {
  state: FetchState<T>;
  refetch: () => void;
  reset: () => void;
} {
  const { enabled = true, maxRetries = 3 } = options;
  const [state, setState] = useState<FetchState<T>>({ status: 'idle' });

  const fetch = useCallback(async () => {
    setState({ status: 'loading' });
    
    const result = await fetcher();
    
    if (result.success) {
      setState({
        status: 'success',
        data: result.data,
        lastFetched: new Date(),
      });
    } else {
      setState(prev => ({
        status: 'error',
        error: result.error,
        retryCount: prev.status === 'error' ? prev.retryCount + 1 : 0,
      }));
    }
  }, [fetcher]);

  useEffect(() => {
    if (enabled) fetch();
  }, [enabled, fetch]);

  return {
    state,
    refetch: fetch,
    reset: () => setState({ status: 'idle' }),
  };
}

// コンポーネントでの使い方
function UserProfile({ userId }: { userId: string }) {
  const { state } = useApiQuery(() =>
    client.request(endpoints.getUser, { id: userId })
  );

  // 型ガードが自動で効く
  switch (state.status) {
    case 'idle':
    case 'loading':
      return <Spinner />;
    case 'error':
      return <ErrorMessage code={state.error.code} />; // state.errorは確実に存在
    case 'success':
      return <div>{state.data.name}</div>; // state.dataは確実に存在
  }
}

discriminated unionって説明するのが若干めんどくさいんだけど、チームに浸透したら「undefinedチェック忘れたバグ」がほぼ撲滅された。新しく入ったメンバーも「最初は慣れなかったけど今は逆にないと不安」と言ってくれていて、これは地味に嬉しかった。

TypeScriptで本当に開発速度が上がった話|3年の運用で見えた正解でも似たような観点が書かれているので、参考にしてみてほしい。

型安全性のレベル比較と実務での判断基準

どこまで型で守るかって、正直チームの習熟度とトレードオフがある。うちのチームで試した結果をまとめた。

アプローチ型安全性実装コスト学習コスト実務向き度
any多用★☆☆☆☆★☆☆☆☆★☆☆☆☆
基本型定義★★★☆☆★★☆☆☆★★☆☆☆
discriminated union★★★★☆★★★☆☆★★★☆☆
テンプレートリテラル型★★★★☆★★★★☆★★★★☆
完全型推論★★★★★★★★★★★★★★★

正直、テンプレートリテラル型を全面採用するのは過剰になることも多い。型定義ファイルが複雑になって、メンバーが「この型エラー何言ってるかわからない」となったことも何度かあった。基本型定義とdiscriminated unionの組み合わせが現実的なバランスだと思っている。

実測値で見る型安全化の効果

うちのチームで型安全化を段階的に進めた6ヶ月の変化を振り返ると、以下のようなデータが出ている。

xychart-beta
  title "型安全化導入後の月別バグ件数推移"
  x-axis ["1月", "2月", "3月", "4月", "5月", "6月"]
  y-axis "バグ件数" 0 --> 25
  line [22, 18, 14, 9, 6, 4]
  bar [22, 18, 14, 9, 6, 4]

1月が型安全化開始前で、3月にAPIクライアント層の全面書き直し、5月にdiscriminated unionをチーム全体に展開した。数字でこれだけ出るとは正直思ってなかったので、結果を見たときは少し驚いた。

ただ、これはバグの数だけじゃなくて「型エラーで事前に気づけた問題」が増えたから相対的に本番バグが減った、という側面が大きい。型でしか担保できない部分があるので、テストとの組み合わせが重要になる。

型生成の自動化:OpenAPIとの連携

型を手書きし続けるのは現実的じゃないので、うちのチームではOpenAPI 3.1からの型自動生成を導入している。

# openapi-typescript(2026年時点でv7が最新)
npx openapi-typescript ./api-schema.yaml --output ./src/types/api.d.ts

生成された型を使って先ほどのAPIクライアントと組み合わせる。

// 生成された型(例)
import type { paths } from './types/api';

// パスからレスポンス型を取り出すユーティリティ
type GetResponse<
  TPath extends keyof paths,
  TMethod extends keyof paths[TPath]
> = paths[TPath][TMethod] extends {
  responses: { 200: { content: { 'application/json': infer R } } };
}
  ? R
  : never;

// 使い方
type UserListResponse = GetResponse<'/api/users', 'get'>;
// バックエンドのOpenAPIスキーマが更新されたら、自動で型も変わる

これを導入してから「バックエンドが型変えたのに気づかなかった問題」がほぼ消えた。CIでスキーマが変わったときに型生成→tscを走らせるようにしているので、PR時点で気づける。

自動化の実装についてはGitHub Actions3年本番運用で気付いた、セルフホストランナーの落とし穴と対策も参考になる。うちのCI/CD構成はあの記事に近い感じで組んでいる。

正直、OpenAPIからの型生成を導入するだけでも、心理的な安心感が全く違う。皆さんのチームではもう導入していますか?まだ手書きしている場合は、導入コスト以上に得られるものが大きいと思うので試してみてほしい。

まとめ

実務でTypeScript 5.8を使い込んだ結果、以下の点が特に効いた。

  • satisfies演算子とテンプレートリテラル型の組み合わせでパスパラメータを型安全に扱える。型で落とし穴を防げる箇所が増えた
  • discriminated unionで状態管理を設計すると、undefinedチェック漏れや状態の取り違えバグがほぼゼロになる
  • OpenAPIからの型自動生成をCIに組み込むと、バックエンドとの型ズレを事前に検出できる
  • 型安全性のレベルは段階的に上げる。いきなり完全型推論を目指すとチームが混乱する。基本型定義→discriminated unionの順に展開したのが現実的だった
  • 本番バグ件数は6ヶ月で約82%削減できた(ただしバグ削減は型安全化だけでなくテスト強化との相乗効果)

次のアクション: 既存のanysatisfies+明示的な型定義に置き換えるところから始めるのをおすすめする。OpenAPIスキーマがあるプロジェクトなら、openapi-typescriptを今すぐ試してみてほしい。状態管理の型定義もdiscriminated unionに移行すると、開発体験が大きく改善するはずだ。

正直、最初の2〜3週間は「これ本当に楽になるの?」と思うフェーズがある。でも一度全体に浸透したら、もう戻れなくなる。型安全性への投資は、チームの規模が大きいほどリターンが大きいんだよね。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事