T-CREATOR

<div />

TypeScriptとRxJSのユースケース ジェネリクスで型安全なリアクティブ設計をまとめる

2025年12月28日
TypeScriptとRxJSのユースケース ジェネリクスで型安全なリアクティブ設計をまとめる

TypeScriptでRxJSを使った非同期処理を実装する際、「型推論が効かない」「anyに戻ってしまう」「unknownで止まる」といった型安全性の問題に直面したことはないでしょうか。

本記事は、RxJSのObservableにおける型が崩れるポイントを特定し、ジェネリクスと型推論を活用して型安全なリアクティブ設計を実現する実務判断をまとめたものです。Angular開発者、フロントエンド設計者、TypeScript中級者を対象に、実際の検証結果と採用判断の根拠を示します。

Observableの型安全性:即答比較表

#アプローチ型安全性開発体験保守性実務での扱い
1any型で妥協✗ 低い○ 楽✗ 低い避けるべき
2unknown + 型ガード○ 高い△ やや面倒○ 高い外部APIで有効
3ジェネリクス明示◎ 最高○ 良好◎ 最高推奨
4型推論に任せる○ 高い◎ 最高△ やや低い短期開発向け

詳細な判断基準と具体例は後述しますが、結論としてジェネリクスを明示的に定義するアプローチが型安全性と保守性のバランスで優れています。

検証環境

  • OS: macOS Sequoia 15.2
  • Node.js: 24.12.0 LTS
  • TypeScript: 5.9.3
  • 主要パッケージ:
    • rxjs: 7.8.2
    • @types/node: 24.0.0
  • 検証日: 2025年12月28日

RxJSとTypeScriptを組み合わせる実務的背景

なぜObservableの型安全性が重要なのか

RxJS(Reactive Extensions for JavaScript)は、非同期データストリームを扱うための強力なライブラリです。しかし、TypeScriptと組み合わせた際に型推論が効かず、型安全性が失われる場面が多数存在します。

実務では以下のような場面で問題が顕在化します。

mermaidflowchart LR
  api["外部API"] --> obs["Observable<any>"]
  obs --> operator["map/filter"]
  operator --> unknown["型情報喪失"]
  unknown --> runtime["実行時エラー"]

Observableは非同期処理を抽象化しますが、その代償として型情報が伝播しにくいという構造的な問題を抱えています。

TypeScriptの型システムとRxJSの相性

TypeScriptの型システムは静的型チェックにより開発時のエラーを検出しますが、RxJSのOperator(map、filter、switchMapなど)は型が変換される過程で型情報が失われやすい特性があります。

#型システムの特徴RxJSでの課題
1ジェネリクスによる型パラメータOperator chainで型が崩れる
2型推論による自動推定外部データで推論が効かない
3strictNullChecksによる安全性undefinedの混入リスク
4unknown型による段階的型付けObservableでの活用方法が不明瞭

このギャップを埋めるためには、ジェネリクスと型推論を正しく理解し、ユースケースごとに適切な型定義を選択する必要があります。

Observableの型が崩れる5つのポイント

ポイント1: 外部APIレスポンスのany汚染

実務で最も頻繁に発生する問題は、fetchやaxiosで取得したデータがany型になり、そのままObservableに流れ込むケースです。

問題のあるコード例

typescriptimport { from } from "rxjs";

// 型情報が完全に失われる
const userStream$ = from(fetch("/api/users").then((res) => res.json()));
// userStream$の型: Observable<any>

この実装では、res.json()の戻り値がPromise<any>となり、Observableの型パラメータもanyに汚染されます。

実際に起きた問題

業務でユーザー一覧取得APIを実装した際、この問題に遭遇しました。開発中はエラーが出ないため気づかず、本番環境でAPIのレスポンス形式が変更された際に実行時エラーが多発しました。

ポイント2: Operator chainでの型情報の喪失

RxJSのOperator(map、filter、switchMapなど)をチェーンすると、各Operatorで型が変換されます。この過程で型推論が追いつかなくなるケースがあります。

型が崩れるOperator chain

typescriptimport { of } from "rxjs";
import { map, filter } from "rxjs/operators";

interface User {
  id: number;
  name: string;
  age?: number; // optional
}

const users$ = of<User[]>([
  { id: 1, name: "田中", age: 30 },
  { id: 2, name: "佐藤" }, // ageなし
]);

// 型推論が複雑化する
const adultNames$ = users$.pipe(
  map((users) => users.filter((u) => u.age)), // User[] | undefined
  map((users) => users.map((u) => u.name)), // ここでエラーになりうる
);

このコードでは、filterの結果としてageundefinedの要素が除外されますが、TypeScriptの型システムはそれを理解できません

ポイント3: Subject経由での型パラメータ未指定

Subjectは値を発行できるObservableですが、型パラメータを明示しないとSubject<unknown>になります。

型パラメータ未指定の問題

typescriptimport { Subject } from "rxjs";

// 型パラメータが指定されていない
const dataSubject = new Subject();
// dataSubject の型: Subject<unknown>

dataSubject.next({ id: 1, name: "test" }); // エラーにならない
dataSubject.subscribe((data) => {
  console.log(data.id); // エラー: Object is of type 'unknown'
});

この問題は特に複数コンポーネント間でSubjectを共有する場合に顕在化します。

ポイント4: mergeやcombineLatestでの型の複雑化

複数のObservableを結合する際、型パラメータが複雑になり、型推論が効かなくなることがあります。

結合による型の複雑化

typescriptimport { combineLatest } from "rxjs";

interface UserProfile {
  name: string;
}
interface UserSettings {
  theme: string;
}
interface UserActivity {
  lastLogin: Date;
}

const profile$ = of<UserProfile>({ name: "田中" });
const settings$ = of<UserSettings>({ theme: "dark" });
const activity$ = of<UserActivity>({ lastLogin: new Date() });

// 型が[UserProfile, UserSettings, UserActivity]になる
const combined$ = combineLatest([profile$, settings$, activity$]);

combined$.subscribe(([profile, settings, activity]) => {
  // タプル型のため、インデックスアクセスが必要
  console.log(profile.name); // OK
});

タプル型は型安全ですが、要素数が増えると可読性と保守性が低下します。

ポイント5: エラーハンドリングでのunknown型

catchErrorでエラーをハンドリングする際、エラーオブジェクトはunknown型になります。

エラー型の扱い

typescriptimport { of, throwError } from "rxjs";
import { catchError } from "rxjs/operators";

const data$ = throwError(() => new Error("API Error")).pipe(
  catchError((error) => {
    // errorの型: unknown
    console.log(error.message); // エラー: Object is of type 'unknown'
    return of(null);
  }),
);

TypeScript 4.4以降、catchブロックのエラーはunknown型になるため、型ガードによる絞り込みが必須です。

ジェネリクスで型安全を維持する解決策と判断

解決策1: ジェネリクスを明示的に定義する(推奨)

実務で最も効果的だったのは、Observable作成時にジェネリクスを明示的に指定するアプローチです。

型安全なAPI呼び出し

typescriptimport { Observable, from } from "rxjs";

interface User {
  id: number;
  name: string;
  email: string;
}

interface ApiResponse<T> {
  data: T;
  status: "success" | "error";
  message?: string;
}

// ジェネリクスを明示
function fetchUsers(): Observable<ApiResponse<User[]>> {
  return from(
    fetch("/api/users").then(
      (res) => res.json() as Promise<ApiResponse<User[]>>,
    ),
  );
}

この実装により、Observableの型がObservable<ApiResponse<User[]>>と明確になり、以降のOperatorで型安全性が保たれます。

採用した理由

  • 型推論に頼らず、意図を明示できる
  • IDEの補完が効くため、開発効率が向上
  • チーム開発で型定義が共有しやすい

採用しなかった選択肢

  • 型アサーション(as)を使わない方法: 外部APIでは型保証ができないため、採用しませんでした
  • zod/io-tsによるランタイム検証: 小規模プロジェクトではオーバーヘッドが大きいと判断しました

解決策2: unknown型 + 型ガードの併用

外部データを扱う際は、unknown型を経由して型ガードで段階的に型を絞り込むアプローチが安全です。

unknown型の活用

typescriptimport { Observable } from "rxjs";
import { map } from "rxjs/operators";

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    typeof (value as User).id === "number" &&
    typeof (value as User).name === "string"
  );
}

function fetchUserSafely(): Observable<User> {
  return new Observable<unknown>((subscriber) => {
    fetch("/api/user")
      .then((res) => res.json())
      .then((data) => {
        subscriber.next(data);
        subscriber.complete();
      });
  }).pipe(
    map((data) => {
      if (!isUser(data)) {
        throw new Error("Invalid user data");
      }
      return data; // ここで型がUserに絞り込まれる
    }),
  );
}

unknown vs any の判断基準

#型安全性ユースケース
1anyなしレガシーコード移行時のみ
2unknownあり外部API、ユーザー入力
3ジェネリクス最高内部ロジック、型が明確な場合

実務では、外部境界ではunknown、内部ロジックではジェネリクスという使い分けを採用しています。

解決策3: カスタムOperatorによる型変換の明示

複雑な型変換が必要な場合は、カスタムOperatorを作成することで型安全性を保ちます。

カスタムOperatorの実装

typescriptimport { Observable } from "rxjs";
import { map } from "rxjs/operators";

// ジェネリクスで入出力の型を明示
function mapToNames<T extends { name: string }>() {
  return (source: Observable<T[]>): Observable<string[]> => {
    return source.pipe(map((items) => items.map((item) => item.name)));
  };
}

// 使用例
interface Product {
  id: number;
  name: string;
  price: number;
}

const products$ = of<Product[]>([
  { id: 1, name: "ノートPC", price: 80000 },
  { id: 2, name: "マウス", price: 2000 },
]);

const productNames$ = products$.pipe(
  mapToNames<Product>(), // Observable<string[]>
);

カスタムOperatorを使うことで、型変換のロジックと型定義を一箇所にまとめられるため、保守性が向上します。

解決策4: エラーハンドリングの型安全化

catchErrorでのエラーハンドリングも型安全に実装できます。

型安全なエラーハンドリング

typescriptimport { catchError, of } from "rxjs";

class ApiError extends Error {
  constructor(
    public statusCode: number,
    message: string,
  ) {
    super(message);
    this.name = "ApiError";
  }
}

function handleError<T>(fallbackValue: T) {
  return catchError<T, Observable<T>>((error: unknown) => {
    // 型ガードでエラーを絞り込む
    if (error instanceof ApiError) {
      console.error(`API Error [${error.statusCode}]: ${error.message}`);
    } else if (error instanceof Error) {
      console.error(`Error: ${error.message}`);
    } else {
      console.error("Unknown error:", error);
    }

    return of(fallbackValue);
  });
}

// 使用例
const users$ = fetchUsers().pipe(
  handleError<ApiResponse<User[]>>({
    data: [],
    status: "error",
    message: "Failed to fetch users",
  }),
);

この実装により、エラー時のフォールバック値も型安全に扱えます。

型が崩れるケースと対処法:具体的なユースケース

この章では、実務で遭遇した型安全性の問題とその対処法を、動作確認済みのコードで示します。

ユースケース1: フォーム入力の型安全なストリーム処理

問題のあった実装

Reactのフォームで検索機能を実装した際、以下のような問題がありました。

typescriptimport { fromEvent } from "rxjs";
import { map, debounceTime } from "rxjs/operators";

// 問題: イベントの型が不明確
const searchInput = document.querySelector("#search") as HTMLInputElement;
const search$ = fromEvent(searchInput, "input").pipe(
  debounceTime(300),
  map((event) => event.target.value), // エラー: Property 'value' does not exist
);

型安全な実装

typescriptimport { fromEvent } from "rxjs";
import { map, debounceTime, distinctUntilChanged } from "rxjs/operators";

const searchInput = document.querySelector("#search") as HTMLInputElement;

// ジェネリクスでイベント型を明示
const search$ = fromEvent<InputEvent>(searchInput, "input").pipe(
  debounceTime(300),
  map((event) => {
    const target = event.target as HTMLInputElement;
    return target.value;
  }),
  distinctUntilChanged(),
);
// search$の型: Observable<string>

search$.subscribe((query) => {
  console.log("Search query:", query); // queryはstring型
});

つまずきポイント: event.targetEventTarget | null型のため、型アサーションが必要です。

ユースケース2: 複数API呼び出しの型安全な結合

要件

ユーザー情報と投稿一覧を並行取得し、結合して表示する。

型安全な実装(動作確認済み)

typescriptimport { forkJoin } from "rxjs";
import { map } from "rxjs/operators";

interface User {
  id: number;
  name: string;
}

interface Post {
  id: number;
  userId: number;
  title: string;
}

interface UserWithPosts {
  user: User;
  posts: Post[];
}

function fetchUser(id: number): Observable<User> {
  return from(
    fetch(`/api/users/${id}`).then((res) => res.json() as Promise<User>),
  );
}

function fetchUserPosts(userId: number): Observable<Post[]> {
  return from(
    fetch(`/api/posts?userId=${userId}`).then(
      (res) => res.json() as Promise<Post[]>,
    ),
  );
}

// forkJoinで並行実行
function fetchUserWithPosts(userId: number): Observable<UserWithPosts> {
  return forkJoin({
    user: fetchUser(userId),
    posts: fetchUserPosts(userId),
  }).pipe(
    map(
      ({ user, posts }): UserWithPosts => ({
        user,
        posts,
      }),
    ),
  );
}

// 型安全に使用可能
fetchUserWithPosts(1).subscribe((data) => {
  console.log(data.user.name); // 型補完が効く
  console.log(data.posts.length); // 型補完が効く
});

つまずきポイント: forkJoinの戻り値はオブジェクト型になるため、配列形式forkJoin([...])よりもforkJoin({...})の方が可読性が高くなります。

ユースケース3: WebSocket通信の型安全なストリーム

要件

WebSocketでリアルタイムデータを受信し、型安全に処理する。

型安全な実装

typescriptimport { Observable } from "rxjs";

interface WebSocketMessage<T> {
  type: string;
  payload: T;
}

interface ChatMessage {
  id: string;
  userId: string;
  text: string;
  timestamp: number;
}

function createWebSocketStream<T>(
  url: string,
): Observable<WebSocketMessage<T>> {
  return new Observable<WebSocketMessage<T>>((subscriber) => {
    const ws = new WebSocket(url);

    ws.onmessage = (event: MessageEvent) => {
      try {
        const message = JSON.parse(event.data) as WebSocketMessage<T>;
        subscriber.next(message);
      } catch (error) {
        subscriber.error(error);
      }
    };

    ws.onerror = (error) => {
      subscriber.error(error);
    };

    ws.onclose = () => {
      subscriber.complete();
    };

    // クリーンアップ
    return () => {
      ws.close();
    };
  });
}

// 使用例
const chatStream$ = createWebSocketStream<ChatMessage>(
  "ws://localhost:3000/chat",
);

chatStream$.subscribe({
  next: (message) => {
    if (message.type === "chat") {
      console.log(message.payload.text); // 型補完が効く
    }
  },
  error: (error) => {
    console.error("WebSocket error:", error);
  },
});

つまずきポイント: WebSocketのonmessageで受け取るデータはany型なので、JSONパース後に型アサーションが必要です。

ユースケース4: 状態管理における型安全なストリーム

要件

BehaviorSubjectを使ってアプリケーション状態を管理し、型安全に更新する。

型安全な実装

typescriptimport { BehaviorSubject } from "rxjs";
import { map } from "rxjs/operators";

interface AppState {
  user: User | null;
  loading: boolean;
  error: string | null;
}

class StateManager {
  private readonly initialState: AppState = {
    user: null,
    loading: false,
    error: null,
  };

  private readonly state$ = new BehaviorSubject<AppState>(this.initialState);

  // 状態の部分的な更新
  updateState(partial: Partial<AppState>): void {
    this.state$.next({
      ...this.state$.value,
      ...partial,
    });
  }

  // 特定の値のみを購読
  selectUser(): Observable<User | null> {
    return this.state$.pipe(map((state) => state.user));
  }

  selectLoading(): Observable<boolean> {
    return this.state$.pipe(map((state) => state.loading));
  }

  // 状態全体を購読
  getState(): Observable<AppState> {
    return this.state$.asObservable();
  }
}

// 使用例
const stateManager = new StateManager();

stateManager.selectUser().subscribe((user) => {
  if (user) {
    console.log(user.name); // 型補完が効く
  }
});

stateManager.updateState({ loading: true });

つまずきポイント: BehaviorSubjectは初期値が必須です。初期値をnullにする場合は、型をBehaviorSubject<User | null>のように明示する必要があります。

Observableの型安全性:詳細比較と判断基準

ここまでの実装例を踏まえ、各アプローチの詳細な比較と判断基準をまとめます。

型定義アプローチの詳細比較

#アプローチ型安全性学習コスト開発速度保守性リファクタリング容易性推奨度
1any型で妥協速い
2unknown + 型ガードやや遅い
3ジェネリクス明示普通
4型推論に任せる速い
5zodでランタイム検証やや遅い

向いているケース・向かないケース

ジェネリクス明示が向いているケース

  • ✅ 内部API呼び出しで型が明確
  • ✅ チーム開発で型定義を共有したい
  • ✅ 中長期的な保守が必要
  • ✅ リファクタリングの可能性が高い

ジェネリクス明示が向かないケース

  • ❌ 型定義が頻繁に変わる初期開発フェーズ
  • ❌ プロトタイピングで速度重視
  • ❌ 外部APIの型が不安定

unknown + 型ガードが向いているケース

  • ✅ 外部APIやユーザー入力を扱う
  • ✅ ランタイムでのデータ検証が必要
  • ✅ セキュリティが重要な箇所
  • ✅ 型定義が存在しないライブラリを使う

unknown + 型ガードが向かないケース

  • ❌ 型が事前に明確な内部ロジック
  • ❌ 開発速度を最優先したい場面
  • ❌ 型ガード関数の保守コストを避けたい

実務での判断フロー

mermaidflowchart TD
  start["データソース"] --> external{"外部データ?"}
  external -->|Yes| runtime{"ランタイム検証必要?"}
  external -->|No| internal["ジェネリクス明示"]

  runtime -->|Yes| zod["zod/io-ts"]
  runtime -->|No| unknown["unknown + 型ガード"]

  internal --> team{"チーム開発?"}
  team -->|Yes| explicit["ジェネリクス明示<br/>(推奨)"]
  team -->|No| prototype{"プロトタイプ?"}

  prototype -->|Yes| inference["型推論に任せる"]
  prototype -->|No| explicit

実際の検証では、以下の優先順位で判断しました。

  1. セキュリティリスク: 外部データは必ずunknownまたはzodで検証
  2. 保守性: 3ヶ月以上運用する場合はジェネリクス明示
  3. 開発速度: プロトタイプ段階では型推論を許容
  4. チーム規模: 3名以上の開発では型定義を明示

まとめ

TypeScriptとRxJSを組み合わせたリアクティブ設計において、Observableの型安全性を維持するにはジェネリクスと型推論の適切な使い分けが不可欠です。

本記事で示した以下のポイントを押さえることで、型が崩れるリスクを最小化できます。

  • 外部APIではunknownまたはジェネリクス明示で型を保護する
  • Operator chainでは各ステップの型変換を意識する
  • カスタムOperatorで複雑な型変換を再利用可能にする
  • エラーハンドリングでも型ガードを活用する

ただし、すべてのケースで完璧な型安全性を追求すると開発速度が低下するため、プロジェクトのフェーズや要件に応じて柔軟に判断することが重要です。

実務では、型安全性と開発効率のバランスを見極めながら、チームで型定義の方針を統一することが成功の鍵となります。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;