T-CREATOR

Electron IPC 設計チートシート:チャネル命名・型安全・エラーハンドリング定型

Electron IPC 設計チートシート:チャネル命名・型安全・エラーハンドリング定型

Electron アプリケーション開発において、メインプロセスとレンダラープロセス間の通信(IPC)は中核的な機能です。しかし、チャネル名の命名が統一されていなかったり、型安全性が確保されていなかったりすると、バグの温床になってしまいます。本記事では、実務で即活用できる IPC 設計の定型パターンをチートシート形式でご紹介します。チャネル命名規則から型安全な実装、堅牢なエラーハンドリングまで、具体的なコード例とともに解説していきますので、ぜひ最後までご覧ください。

早見表

チャネル命名規則早見表

#分類命名パターン用途
1要求・応答操作:対象:動詞window:main:minimizeメインプロセスへの操作要求
2データ取得get:対象:詳細get:user:profileデータ取得リクエスト
3データ送信send:対象:詳細send:form:submitデータ送信
4通知notify:対象:イベントnotify:download:completeイベント通知
5購読subscribe:対象:更新subscribe:settings:change状態変更の購読

型安全パターン早見表

#手法メリット適用場面
1TypeScript 型定義コンパイル時チェック全ての IPC チャネル
2Zod バリデーション実行時型検証ユーザー入力、外部データ
3型アサーション型推論サポートレスポンス受信時
4ジェネリクス活用再利用性向上共通ヘルパー関数

エラーハンドリング定型パターン早見表

#パターン実装場所エラー種別
1try-catch + Result 型ipcMain.handle同期エラー
2Promise reject非同期処理非同期エラー
3イベントエラー通知ipcMain.onリスナー内エラー
4タイムアウト制御レンダラープロセス応答遅延エラー
5リトライ機構ヘルパー関数一時的なエラー

背景

Electron IPC の基本構造

Electron では、セキュリティの観点から メインプロセス(Node.js 環境)と レンダラープロセス(ブラウザ環境)が分離されています。これらのプロセス間でデータをやり取りするには、IPC(Inter-Process Communication)という仕組みを使います。

主な IPC の方式は以下の通りです。

  • ipcRenderer.invoke / ipcMain.handle:要求・応答型の双方向通信
  • ipcRenderer.send / ipcMain.on:一方向のイベント送信
  • webContents.send:メインからレンダラーへのプッシュ通知

これらの IPC を活用することで、レンダラープロセスから OS 機能へのアクセスや、データベース操作、ファイルシステムの操作などを安全に実行できます。

プロセス間通信の全体像

以下の図は、Electron の IPC がどのように機能するかを示したものです。

mermaidflowchart TB
  renderer["レンダラープロセス<br/>(React/Vue等)"]
  preload["preload.js<br/>(contextBridge)"]
  main["メインプロセス<br/>(ipcMain)"]
  os["OS機能<br/>(ファイル/DB等)"]

  renderer -->|"invoke('channel')"| preload
  preload -->|"ipcRenderer.invoke"| main
  main -->|"handle('channel')"| os
  os -->|"戻り値"| main
  main -->|"Promise resolve"| preload
  preload -->|"型付き戻り値"| renderer

上記の図からわかるように、レンダラープロセスは直接メインプロセスにアクセスせず、preload.js を経由することでセキュリティを確保しています。

IPC 設計における課題の発生要因

Electron の IPC は柔軟である一方、以下のような問題が起きやすくなっています。

  • 命名の自由度が高い:チャネル名を任意の文字列で定義できるため、プロジェクトが大きくなると命名が乱れる
  • 型情報の欠如:JavaScript では実行時までデータ型がわからず、予期しないデータ構造でエラーが発生
  • エラーハンドリングの複雑さ:非同期処理が絡むため、エラーが見逃されやすい

これらの課題を放置すると、保守性が低下し、バグの原因特定に多大な時間を要することになります。

課題

チャネル名の命名が統一されない問題

IPC のチャネル名は文字列で指定しますが、命名規則がないと以下のような問題が発生します。

  • 可読性の低下getUserDataget-user-profileuser:fetch など、命名スタイルがバラバラ
  • 重複リスク:同じ機能に対して異なるチャネル名が複数存在
  • 検索性の悪化:コードベース全体でチャネル名を追跡しづらい

例えば、あるエンジニアが window-minimize というチャネル名を使い、別のエンジニアが minimizeWindow を使うと、同じ機能が重複してしまいます。

型安全性が確保されない問題

JavaScript のデフォルトでは、IPC で送受信するデータの型が保証されません。これにより以下の問題が起きます。

typescript// レンダラー側
const result = await window.api.getUser(123);
// result の型が不明、string か object か number か?

// 実行時エラーが発生しやすい
console.log(result.name.toUpperCase()); // result が undefined だとエラー

上記のように、受け取ったデータの構造がわからないため、実行時にエラーが発生するリスクが高まります。

エラーハンドリングの欠如

IPC は非同期処理が中心のため、エラーハンドリングを適切に実装しないと以下の問題が発生します。

  • エラーの握りつぶし:try-catch を書き忘れるとエラーがユーザーに伝わらない
  • 不明瞭なエラーメッセージ:「Error: Unknown」だけでは原因がわからない
  • エラー発生箇所の特定困難:メインプロセスとレンダラープロセスのどちらでエラーが起きたか不明

これらの課題を解決するには、体系的な設計パターンが必要です。

課題の関係性

以下の図は、IPC 設計における課題がどのように関連しているかを示しています。

mermaidflowchart TD
  naming["命名規則の欠如"]
  type["型安全性の欠如"]
  error["エラーハンドリングの欠如"]
  bug["バグの増加"]
  maintenance["保守コスト増大"]

  naming --> bug
  type --> bug
  error --> bug
  bug --> maintenance

  naming -.->|"コード検索困難"| maintenance
  type -.->|"リファクタ困難"| maintenance

これらの課題は独立しているのではなく、相互に影響し合って保守コストを増大させます。

解決策

チャネル命名規則の統一

チャネル名には 一貫性のある命名パターン を適用することが重要です。以下のパターンを推奨します。

パターン 1:操作型チャネル(操作:対象:動詞

ウィンドウ操作やアプリケーション制御など、何かを実行する場合に使用します。

typescript// 命名例
'window:main:minimize'; // メインウィンドウを最小化
'window:main:maximize'; // メインウィンドウを最大化
'app:system:restart'; // アプリケーション再起動
'dialog:file:open'; // ファイル選択ダイアログを開く

このパターンでは、操作対象(window、app など)と動作(minimize、restart など)が明確になります。

パターン 2:データ取得型チャネル(get:対象:詳細

データベースや設定ファイルからデータを取得する場合に使用します。

typescript// 命名例
'get:user:profile'; // ユーザープロファイル取得
'get:settings:theme'; // テーマ設定取得
'get:database:records'; // データベースレコード取得
'get:file:content'; // ファイル内容取得

パターン 3:データ送信型チャネル(send:対象:詳細

データを保存したり、サーバーに送信したりする場合に使用します。

typescript// 命名例
'send:form:submit'; // フォーム送信
'send:settings:update'; // 設定更新
'send:log:error'; // エラーログ送信

パターン 4:通知型チャネル(notify:対象:イベント

メインプロセスからレンダラープロセスへの通知に使用します。

typescript// 命名例
'notify:download:complete'; // ダウンロード完了通知
'notify:update:available'; // アップデート利用可能通知
'notify:network:offline'; // オフライン通知

型安全性の確保

TypeScript を活用し、IPC のチャネルとデータ構造を型定義します。

型定義ファイルの作成

まず、共通の型定義ファイルを作成します。

typescript// types/ipc.ts

// ユーザープロファイルの型定義
export interface UserProfile {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
}

// 設定情報の型定義
export interface AppSettings {
  theme: 'light' | 'dark';
  language: 'ja' | 'en';
  autoSave: boolean;
}

上記のように、送受信するデータの構造を明確に定義します。

チャネル定義オブジェクトの作成

チャネル名と型を紐付けるオブジェクトを作成します。

typescript// types/channels.ts

import { UserProfile, AppSettings } from './ipc';

// IPC チャネルの型定義
export const IPC_CHANNELS = {
  'get:user:profile': {
    request: { userId: 0 as number },
    response: {} as UserProfile,
  },
  'get:settings:theme': {
    request: null,
    response: {} as AppSettings,
  },
  'send:settings:update': {
    request: {} as Partial<AppSettings>,
    response: { success: true as boolean },
  },
} as const;

// チャネル名の型
export type ChannelName = keyof typeof IPC_CHANNELS;

このオブジェクトにより、チャネル名とデータ型が一元管理されます。

型安全な invoke ヘルパーの作成

ジェネリクスを活用して、型安全な IPC ヘルパー関数を作成します。

typescript// preload.ts

import { contextBridge, ipcRenderer } from 'electron';
import {
  IPC_CHANNELS,
  ChannelName,
} from './types/channels';

// 型安全な invoke 関数
async function typedInvoke<T extends ChannelName>(
  channel: T,
  data: (typeof IPC_CHANNELS)[T]['request']
): Promise<(typeof IPC_CHANNELS)[T]['response']> {
  return await ipcRenderer.invoke(channel, data);
}

上記の関数により、チャネル名を指定すると、自動的にリクエストとレスポンスの型が推論されます。

contextBridge での公開

作成したヘルパー関数を contextBridge で公開します。

typescript// preload.ts(続き)

contextBridge.exposeInMainWorld('api', {
  invoke: typedInvoke,
});

これにより、レンダラープロセスから型安全に IPC を呼び出せます。

レンダラープロセスでの利用

レンダラープロセスでは、型推論が効いた状態で IPC を利用できます。

typescript// renderer.ts

// 型推論が効く!
const profile = await window.api.invoke(
  'get:user:profile',
  { userId: 123 }
);
// profile は UserProfile 型として推論される
console.log(profile.name); // エラーが出ない

// 型チェックが効く!
await window.api.invoke('send:settings:update', {
  theme: 'dark', // OK
  language: 'ja', // OK
  // typo: 'error' // コンパイルエラー!
});

Zod によるランタイムバリデーション

型定義だけでは実行時の型安全性は保証されません。Zod を使ってランタイムバリデーションを追加します。

Zod スキーマの定義

typescript// schemas/user.ts

import { z } from 'zod';

// ユーザープロファイルのスキーマ
export const UserProfileSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  createdAt: z.date(),
});

// TypeScript 型を生成
export type UserProfile = z.infer<typeof UserProfileSchema>;

メインプロセスでのバリデーション

受け取ったデータを Zod でバリデーションします。

typescript// main.ts

import { ipcMain } from 'electron';
import { UserProfileSchema } from './schemas/user';

ipcMain.handle('get:user:profile', async (event, data) => {
  // リクエストデータのバリデーション
  const validated = z
    .object({ userId: z.number() })
    .parse(data);

  // データベースから取得(仮)
  const user = await fetchUserFromDB(validated.userId);

  // レスポンスデータのバリデーション
  const validatedUser = UserProfileSchema.parse(user);

  return validatedUser;
});

上記により、不正なデータが混入した場合は即座にエラーが発生し、バグを早期発見できます。

Result 型によるエラーハンドリング

エラーを明示的に扱うために、Result 型パターンを導入します。

Result 型の定義

typescript// types/result.ts

// 成功型
export type Success<T> = {
  success: true;
  data: T;
};

// 失敗型
export type Failure = {
  success: false;
  error: {
    code: string;
    message: string;
    details?: unknown;
  };
};

// Result 型
export type Result<T> = Success<T> | Failure;

Result 型を使うことで、成功と失敗を型レベルで区別できます。

メインプロセスでの Result 型適用

typescript// main.ts

import { ipcMain } from 'electron';
import { Result } from './types/result';
import { UserProfile } from './schemas/user';

ipcMain.handle(
  'get:user:profile',
  async (event, data): Promise<Result<UserProfile>> => {
    try {
      const validated = z
        .object({ userId: z.number() })
        .parse(data);
      const user = await fetchUserFromDB(validated.userId);
      const validatedUser = UserProfileSchema.parse(user);

      // 成功時
      return {
        success: true,
        data: validatedUser,
      };
    } catch (err) {
      // 失敗時
      return {
        success: false,
        error: {
          code: 'USER_FETCH_ERROR',
          message: 'ユーザー情報の取得に失敗しました',
          details: err,
        },
      };
    }
  }
);

上記のように、すべてのエラーを catch し、統一された形式で返します。

レンダラープロセスでのエラーハンドリング

typescript// renderer.ts

const result = await window.api.invoke('get:user:profile', {
  userId: 123,
});

if (result.success) {
  // 成功時の処理
  console.log('ユーザー名:', result.data.name);
  displayUserProfile(result.data);
} else {
  // 失敗時の処理
  console.error(`エラーコード: ${result.error.code}`);
  console.error(
    `エラーメッセージ: ${result.error.message}`
  );
  showErrorNotification(result.error.message);
}

Result 型により、エラーハンドリングが型安全かつ明示的になります。

タイムアウト制御の実装

長時間応答がない場合に備えて、タイムアウト処理を実装します。

typescript// utils/timeout.ts

// タイムアウト付き invoke ヘルパー
export async function invokeWithTimeout<T>(
  channel: string,
  data: unknown,
  timeoutMs: number = 5000
): Promise<T> {
  return Promise.race([
    window.api.invoke(channel, data),
    new Promise<never>((_, reject) =>
      setTimeout(
        () =>
          reject(
            new Error(
              `Timeout: ${channel}${timeoutMs}ms 以内に応答しませんでした`
            )
          ),
        timeoutMs
      )
    ),
  ]);
}

このヘルパーを使うことで、応答遅延によるアプリケーションのフリーズを防げます。

タイムアウトヘルパーの利用例

typescript// renderer.ts

try {
  const result = await invokeWithTimeout(
    'get:user:profile',
    { userId: 123 },
    3000
  );
  console.log('取得成功:', result);
} catch (err) {
  if (err.message.startsWith('Timeout:')) {
    console.error('タイムアウトエラー:', err.message);
    showTimeoutError();
  } else {
    console.error('その他のエラー:', err);
  }
}

IPC 設計パターンの全体像

以下の図は、これまで説明した設計パターンがどのように組み合わさるかを示しています。

mermaidflowchart TD
  naming["チャネル命名規則<br/>(操作:対象:動詞)"]
  types["TypeScript型定義<br/>(IPC_CHANNELS)"]
  validation["Zodバリデーション<br/>(実行時検証)"]
  result["Result型<br/>(エラーハンドリング)"]
  timeout["タイムアウト制御<br/>(応答遅延対策)"]

  ipc["堅牢なIPC設計"]

  naming --> ipc
  types --> ipc
  validation --> ipc
  result --> ipc
  timeout --> ipc

  ipc --> quality["高品質・高保守性"]

これらのパターンを組み合わせることで、保守性の高い IPC 設計が実現します。

具体例

ファイル選択ダイアログの実装例

ここでは、ファイル選択ダイアログを開いて、選択されたファイルのパスを取得する実装例を示します。

型定義とスキーマ

typescript// types/file.ts

import { z } from 'zod';

// ファイル選択リクエストのスキーマ
export const FileDialogRequestSchema = z.object({
  title: z.string().optional(),
  filters: z
    .array(
      z.object({
        name: z.string(),
        extensions: z.array(z.string()),
      })
    )
    .optional(),
});

// ファイル選択レスポンスのスキーマ
export const FileDialogResponseSchema = z.object({
  filePath: z.string().nullable(),
  canceled: z.boolean(),
});

// TypeScript 型の生成
export type FileDialogRequest = z.infer<
  typeof FileDialogRequestSchema
>;
export type FileDialogResponse = z.infer<
  typeof FileDialogResponseSchema
>;

メインプロセスの実装

typescript// main.ts

import { ipcMain, dialog } from 'electron';
import { Result } from './types/result';
import {
  FileDialogRequestSchema,
  FileDialogResponseSchema,
  FileDialogResponse,
} from './types/file';

ipcMain.handle(
  'dialog:file:open',
  async (
    event,
    data
  ): Promise<Result<FileDialogResponse>> => {
    try {
      // リクエストバリデーション
      const validated = FileDialogRequestSchema.parse(data);

      // ファイル選択ダイアログを表示
      const result = await dialog.showOpenDialog({
        title: validated.title,
        filters: validated.filters,
        properties: ['openFile'],
      });

      // レスポンス作成
      const response: FileDialogResponse = {
        filePath: result.filePaths[0] || null,
        canceled: result.canceled,
      };

      // レスポンスバリデーション
      const validatedResponse =
        FileDialogResponseSchema.parse(response);

      return {
        success: true,
        data: validatedResponse,
      };
    } catch (err) {
      return {
        success: false,
        error: {
          code: 'FILE_DIALOG_ERROR',
          message:
            'ファイル選択ダイアログでエラーが発生しました',
          details: err,
        },
      };
    }
  }
);

preload.js での公開

typescript// preload.ts

import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('api', {
  openFileDialog: async (options) => {
    return await ipcRenderer.invoke(
      'dialog:file:open',
      options
    );
  },
});

レンダラープロセスでの利用

typescript// renderer.ts

async function selectFile() {
  const result = await window.api.openFileDialog({
    title: '画像ファイルを選択してください',
    filters: [
      { name: '画像', extensions: ['jpg', 'png', 'gif'] },
      { name: 'すべてのファイル', extensions: ['*'] },
    ],
  });

  if (result.success) {
    if (result.data.canceled) {
      console.log('ファイル選択がキャンセルされました');
      return;
    }

    console.log(
      '選択されたファイル:',
      result.data.filePath
    );
    loadImage(result.data.filePath);
  } else {
    console.error('エラーコード:', result.error.code);
    console.error(
      'エラーメッセージ:',
      result.error.message
    );
    alert('ファイル選択中にエラーが発生しました');
  }
}

この実装により、型安全性とエラーハンドリングが両立された堅牢なファイル選択機能が実現できます。

データベース操作の実装例

次に、データベースからユーザー一覧を取得する例を示します。

型定義とスキーマ

typescript// types/database.ts

import { z } from 'zod';

// ユーザー一覧取得リクエスト
export const GetUsersRequestSchema = z.object({
  limit: z.number().int().positive().max(100).default(10),
  offset: z.number().int().nonnegative().default(0),
  sortBy: z.enum(['name', 'email', 'createdAt']).optional(),
});

// ユーザー情報
export const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  email: z.string().email(),
  createdAt: z.string(), // ISO8601形式
});

// ユーザー一覧レスポンス
export const GetUsersResponseSchema = z.object({
  users: z.array(UserSchema),
  total: z.number().int().nonnegative(),
});

export type GetUsersRequest = z.infer<
  typeof GetUsersRequestSchema
>;
export type User = z.infer<typeof UserSchema>;
export type GetUsersResponse = z.infer<
  typeof GetUsersResponseSchema
>;

メインプロセスのデータベース処理

typescript// main.ts

import { ipcMain } from 'electron';
import { Result } from './types/result';
import {
  GetUsersRequestSchema,
  GetUsersResponseSchema,
  GetUsersResponse,
} from './types/database';
import { db } from './database'; // 仮のDBモジュール

ipcMain.handle(
  'get:database:users',
  async (
    event,
    data
  ): Promise<Result<GetUsersResponse>> => {
    try {
      // リクエストバリデーション
      const validated = GetUsersRequestSchema.parse(data);

      // データベースクエリ実行
      const users = await db.query(
        'SELECT * FROM users ORDER BY ? LIMIT ? OFFSET ?',
        [
          validated.sortBy || 'createdAt',
          validated.limit,
          validated.offset,
        ]
      );

      const total = await db.query(
        'SELECT COUNT(*) as count FROM users'
      );

      // レスポンス作成
      const response: GetUsersResponse = {
        users: users.map((u) => ({
          id: u.id,
          name: u.name,
          email: u.email,
          createdAt: u.created_at.toISOString(),
        })),
        total: total[0].count,
      };

      // レスポンスバリデーション
      const validatedResponse =
        GetUsersResponseSchema.parse(response);

      return {
        success: true,
        data: validatedResponse,
      };
    } catch (err) {
      return {
        success: false,
        error: {
          code: 'DATABASE_QUERY_ERROR',
          message: 'ユーザー一覧の取得に失敗しました',
          details: err,
        },
      };
    }
  }
);

レンダラープロセスでのページネーション

typescript// renderer.ts

async function loadUsers(
  page: number = 1,
  pageSize: number = 10
) {
  const offset = (page - 1) * pageSize;

  const result = await window.api.invoke(
    'get:database:users',
    {
      limit: pageSize,
      offset: offset,
      sortBy: 'name',
    }
  );

  if (result.success) {
    const { users, total } = result.data;

    console.log(`ユーザー数: ${users.length} / ${total}`);

    // UIに表示
    displayUserList(users);
    updatePagination(page, Math.ceil(total / pageSize));
  } else {
    console.error('エラーコード:', result.error.code);
    showErrorMessage('ユーザー一覧の取得に失敗しました');
  }
}

リトライ機構の実装

一時的なエラー(ネットワーク切断など)に対応するため、リトライ機構を実装します。

typescript// utils/retry.ts

// リトライ設定の型
interface RetryConfig {
  maxRetries: number; // 最大リトライ回数
  delayMs: number; // リトライ間隔(ミリ秒)
  backoffMultiplier: number; // 遅延倍率(指数バックオフ用)
}

// デフォルト設定
const defaultConfig: RetryConfig = {
  maxRetries: 3,
  delayMs: 1000,
  backoffMultiplier: 2,
};

リトライヘルパー関数

typescript// utils/retry.ts(続き)

export async function invokeWithRetry<T>(
  channel: string,
  data: unknown,
  config: Partial<RetryConfig> = {}
): Promise<T> {
  const { maxRetries, delayMs, backoffMultiplier } = {
    ...defaultConfig,
    ...config,
  };

  let lastError: Error;
  let currentDelay = delayMs;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const result = await window.api.invoke(channel, data);
      return result; // 成功
    } catch (err) {
      lastError = err as Error;

      if (attempt < maxRetries) {
        console.warn(
          `リトライ ${
            attempt + 1
          }/${maxRetries}: ${channel}`
        );
        await sleep(currentDelay);
        currentDelay *= backoffMultiplier; // 指数バックオフ
      }
    }
  }

  throw new Error(
    `${maxRetries}回のリトライ後も失敗: ${lastError.message}`
  );
}

// スリープ関数
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

リトライヘルパーの利用例

typescript// renderer.ts

async function fetchUserWithRetry(userId: number) {
  try {
    const result = await invokeWithRetry(
      'get:user:profile',
      { userId },
      {
        maxRetries: 5,
        delayMs: 500,
        backoffMultiplier: 2,
      }
    );

    console.log('取得成功:', result);
    return result;
  } catch (err) {
    console.error('リトライ後も失敗:', err.message);
    showPersistentErrorMessage(
      'ユーザー情報を取得できませんでした'
    );
    throw err;
  }
}

この実装により、一時的なネットワークエラーなどに対して自動的にリトライし、成功率を向上させることができます。

イベントベース通知の実装例

メインプロセスからレンダラープロセスへの通知機能を実装します。

通知データの型定義

typescript// types/notification.ts

import { z } from 'zod';

// ダウンロード進捗通知
export const DownloadProgressSchema = z.object({
  fileName: z.string(),
  progress: z.number().min(0).max(100),
  downloadedBytes: z.number().nonnegative(),
  totalBytes: z.number().positive(),
});

export type DownloadProgress = z.infer<
  typeof DownloadProgressSchema
>;

メインプロセスでの通知送信

typescript// main.ts

import { BrowserWindow } from 'electron';
import { DownloadProgressSchema } from './types/notification';

// ダウンロード処理(仮)
async function downloadFile(
  url: string,
  mainWindow: BrowserWindow
) {
  // ダウンロードロジック
  let downloaded = 0;
  const total = 1024 * 1024 * 10; // 10MB

  while (downloaded < total) {
    downloaded += 1024 * 100; // 100KB ずつ

    const progress = {
      fileName: 'sample.zip',
      progress: Math.floor((downloaded / total) * 100),
      downloadedBytes: downloaded,
      totalBytes: total,
    };

    // バリデーション
    const validated =
      DownloadProgressSchema.parse(progress);

    // レンダラープロセスに通知
    mainWindow.webContents.send(
      'notify:download:progress',
      validated
    );

    await new Promise((resolve) =>
      setTimeout(resolve, 100)
    );
  }

  // 完了通知
  mainWindow.webContents.send('notify:download:complete', {
    fileName: 'sample.zip',
  });
}

preload.js でのイベントリスナー登録

typescript// preload.ts

import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('api', {
  onDownloadProgress: (callback: (data: any) => void) => {
    ipcRenderer.on(
      'notify:download:progress',
      (event, data) => {
        callback(data);
      }
    );
  },

  onDownloadComplete: (callback: (data: any) => void) => {
    ipcRenderer.on(
      'notify:download:complete',
      (event, data) => {
        callback(data);
      }
    );
  },
});

レンダラープロセスでのイベント購読

typescript// renderer.ts

// ダウンロード進捗の監視
window.api.onDownloadProgress((data) => {
  console.log(`ダウンロード進捗: ${data.progress}%`);
  updateProgressBar(data.progress);

  const downloaded = (
    data.downloadedBytes /
    1024 /
    1024
  ).toFixed(2);
  const total = (data.totalBytes / 1024 / 1024).toFixed(2);
  console.log(`${downloaded} MB / ${total} MB`);
});

// ダウンロード完了の監視
window.api.onDownloadComplete((data) => {
  console.log(`ダウンロード完了: ${data.fileName}`);
  showSuccessNotification(
    `${data.fileName} のダウンロードが完了しました`
  );
  hideProgressBar();
});

まとめ

本記事では、Electron IPC 設計における以下の定型パターンをご紹介しました。

チャネル命名規則

チャネル名は 操作:対象:動詞 のパターンを基本とし、データ取得は get:、データ送信は send:、通知は notify: のプレフィックスを使うことで、コードベース全体で一貫性を保つことができます。命名規則を統一すると、コードの検索性が向上し、チーム開発でもスムーズに連携できるでしょう。

型安全性の確保

TypeScript の型定義と Zod のランタイムバリデーションを組み合わせることで、コンパイル時と実行時の両方で型安全性を確保できます。特に、チャネル定義オブジェクトを使った型推論により、IPC 呼び出し時に自動的に型チェックが働くため、開発効率が大幅に向上します。

エラーハンドリング

Result 型パターンにより、成功と失敗を明示的に扱うことができます。エラーコードとメッセージを統一形式で返すことで、エラー発生時のデバッグが容易になります。また、タイムアウト制御やリトライ機構を組み込むことで、ネットワークエラーなど一時的な障害にも強いアプリケーションを構築できるでしょう。

実務への適用

これらのパターンは、新規プロジェクトだけでなく既存プロジェクトのリファクタリングにも適用可能です。まずはチャネル命名規則から統一し、次に型定義を追加、最後にエラーハンドリングを強化するという段階的なアプローチが効果的です。

Electron IPC は柔軟な反面、設計の自由度が高く、プロジェクトの規模が大きくなるほど保守性が課題になります。本記事で紹介したチートシートを活用することで、チーム全体で統一された IPC 設計を実現し、高品質なアプリケーション開発を進めていただければ幸いです。

関連リンク

;