All skills
jwynia avatar

/electron-best-practices

@075cb8f
by J Wyniajwynia/agent-skills160 stars
20

Guide AI agents through Electron app development with React including security patterns, type-safe IPC, React integration, packaging with code signing, and testing. Keywords: electron, electron-vite, electron-forge, contextBridge, IPC, security, react, packaging, code signing, notarization, playwright, desktop app.

Use this Skill: https://skilld.dev/gh/jwynia/agent-skills/electron-best-practices

This session only. Nothing lands on disk.

referencesipctyped-ipc.md

≈2.4k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Manual Typed IPC with Mapped Types

Overview

Electron's IPC system uses string channel names and untyped arguments by default. This creates a class of bugs that only surface at runtime: misspelled channel names, wrong argument types, mismatched return types between main and renderer. TypeScript mapped types let you define a single source of truth for every IPC channel, then enforce those contracts at compile time across main, preload, and renderer code.

This reference covers the manual approach using TypeScript's type system directly, without third-party libraries.


Why Type-Safe IPC Matters

Without type safety, IPC channels are just strings:

// renderer - sends a number
window.electronAPI.invoke('get-user', 42);

// main - expects a string
ipcMain.handle('get-user', (_event, id: string) => {
  return db.findUser(id); // id is actually 42, not "42"
});

Common failure modes in untyped IPC:

  • Channel name typos: 'save-docment' vs 'save-document' -- silent failure
  • Wrong argument types: sending number where string is expected
  • Wrong argument count: forgetting a required parameter
  • Mismatched return types: renderer expects User, main returns User | null
  • Stale channels: renaming a channel in main but not in preload

All of these are caught at compile time with the pattern below.


The IpcChannelMap Pattern

Define a single type that maps every channel name to its argument tuple and return type. This lives in a shared module imported by both main and renderer code.

// shared/ipc-types.ts

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

export interface Document {
  id: string;
  title: string;
  content: string;
}

/**
 * Maps invoke/handle channel names to their argument and return types.
 * Each key is a channel name. Each value defines the args tuple and return type.
 */
export type IpcChannelMap = {
  'get-user': {
    args: [id: string];
    return: User | null;
  };
  'save-document': {
    args: [doc: Document];
    return: { success: boolean; path: string };
  };
  'get-app-version': {
    args: [];
    return: string;
  };
  'read-file': {
    args: [filePath: string, encoding: BufferEncoding];
    return: string;
  };
  'list-recent-files': {
    args: [];
    return: Array<{ name: string; path: string; modified: number }>;
  };
};

This type serves as the contract. Every layer of the application references it.


One-Way Event Channels

For fire-and-forget messages (main-to-renderer or renderer-to-main), define a separate map without return types:

// shared/ipc-types.ts (continued)

/**
 * Maps one-way event channel names to their argument types.
 * Used for send/on patterns where no response is expected.
 */
export type IpcEventMap = {
  'download-progress': { args: [percent: number] };
  'state-changed': { args: [key: string, value: unknown] };
  'notification': { args: [title: string, body: string] };
  'window-focus-changed': { args: [focused: boolean] };
};

Typed Main Process Handlers

Wrap ipcMain.handle to enforce that the handler signature matches the channel map:

// main/ipc-handler.ts
import { ipcMain, type BrowserWindow } from 'electron';
import type { IpcChannelMap, IpcEventMap } from '../shared/ipc-types';

/**
 * Register a typed invoke/handle pair.
 * The handler's arguments and return type are inferred from IpcChannelMap.
 */
export function handleIpc<K extends keyof IpcChannelMap>(
  channel: K,
  handler: (
    ...args: IpcChannelMap[K]['args']
  ) => Promise<IpcChannelMap[K]['return']> | IpcChannelMap[K]['return']
): void {
  ipcMain.handle(channel, (_event, ...args) => {
    return handler(...(args as IpcChannelMap[K]['args']));
  });
}

/**
 * Remove a typed handler.
 */
export function removeHandler<K extends keyof IpcChannelMap>(channel: K): void {
  ipcMain.removeHandler(channel);
}

/**
 * Send a typed one-way event from main to a renderer window.
 */
export function sendToRenderer<K extends keyof IpcEventMap>(
  window: BrowserWindow,
  channel: K,
  ...args: IpcEventMap[K]['args']
): void {
  window.webContents.send(channel, ...args);
}

Usage in the main process:

// main/handlers/user-handlers.ts
import { handleIpc } from '../ipc-handler';
import { getUserById } from '../services/user-service';

handleIpc('get-user', async (id) => {
  // id is inferred as string
  // return type must be User | null
  return getUserById(id);
});

handleIpc('get-app-version', () => {
  // no args, must return string
  return app.getVersion();
});

// Type error: argument type mismatch
handleIpc('get-user', async (id: number) => {
  //                          ^^^^^^^^^^
  // Error: Type 'number' is not assignable to type 'string'
  return null;
});

Typed Preload Bridge

The preload script creates the bridge between main and renderer. Typed wrappers ensure the exposed API matches the channel contracts:

// preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
import type { IpcChannelMap, IpcEventMap } from '../shared/ipc-types';

/**
 * Type-safe invoke wrapper.
 */
function typedInvoke<K extends keyof IpcChannelMap>(
  channel: K,
  ...args: IpcChannelMap[K]['args']
): Promise<IpcChannelMap[K]['return']> {
  return ipcRenderer.invoke(channel, ...args);
}

/**
 * Type-safe event listener for main-to-renderer events.
 */
function typedOn<K extends keyof IpcEventMap>(
  channel: K,
  callback: (...args: IpcEventMap[K]['args']) => void
): () => void {
  const listener = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
    callback(...(args as IpcEventMap[K]['args']));
  };
  ipcRenderer.on(channel, listener);
  return () => ipcRenderer.removeListener(channel, listener);
}

// Expose typed API to renderer
const electronAPI = {
  getUser: (id: string) => typedInvoke('get-user', id),
  saveDocument: (doc: Parameters<typeof typedInvoke<'save-document'>>[1]) =>
    typedInvoke('save-document', doc),
  getAppVersion: () => typedInvoke('get-app-version'),
  readFile: (path: string, encoding: BufferEncoding) =>
    typedInvoke('read-file', path, encoding),
  listRecentFiles: () => typedInvoke('list-recent-files'),

  onDownloadProgress: (cb: (percent: number) => void) =>
    typedOn('download-progress', cb),
  onStateChanged: (cb: (key: string, value: unknown) => void) =>
    typedOn('state-changed', cb),
};

export type ElectronAPI = typeof electronAPI;

contextBridge.exposeInMainWorld('electronAPI', electronAPI);

Renderer-Side Type Declarations

Declare the global type so the renderer can use window.electronAPI with full type information:

// renderer/global.d.ts
import type { ElectronAPI } from '../preload/index';

declare global {
  interface Window {
    electronAPI: ElectronAPI;
  }
}

Usage in the renderer:

// renderer/components/UserProfile.tsx
async function loadUser(id: string) {
  const user = await window.electronAPI.getUser(id);
  // user is typed as User | null
  if (user) {
    setName(user.name);
  }
}

// Type error: wrong argument type
await window.electronAPI.getUser(42);
//                                ^^
// Error: Argument of type 'number' is not assignable to type 'string'

// Type error: missing argument
await window.electronAPI.readFile('/path/to/file');
// Error: Expected 2 arguments, but got 1

End-to-End Example: Adding a New Channel

To add a new delete-document channel, you touch exactly three places:

Step 1 -- Add to the channel map:

// shared/ipc-types.ts
export type IpcChannelMap = {
  // ...existing channels...
  'delete-document': {
    args: [documentId: string, permanent: boolean];
    return: { deleted: boolean };
  };
};

Step 2 -- Register the handler:

// main/handlers/document-handlers.ts
handleIpc('delete-document', async (documentId, permanent) => {
  // documentId: string, permanent: boolean -- inferred from map
  const deleted = await documentService.delete(documentId, { permanent });
  return { deleted };
});

Step 3 -- Expose in preload:

// preload/index.ts (add to electronAPI object)
deleteDocument: (id: string, permanent: boolean) =>
  typedInvoke('delete-document', id, permanent),

If any layer has a type mismatch, the compiler catches it immediately.


Tradeoffs vs electron-trpc

Aspect Manual Typed IPC electron-trpc
Setup complexity Low -- just TypeScript types Medium -- tRPC + Zod + link setup
Runtime validation None (compile-time only) Full Zod validation at runtime
Bundle size Zero additional deps tRPC + Zod (~30-50KB)
Boilerplate More manual wiring Less -- auto-generated client
Subscriptions Manual event wiring Built-in observable support
React integration Manual state management React Query hooks out of the box
Refactoring safety Good (compile-time) Better (runtime + compile-time)
Learning curve Low (just TypeScript) Medium (tRPC concepts)

Use manual typed IPC when you want zero dependencies and full control over the IPC layer. Use electron-trpc when your app has many channels and you want runtime validation, automatic client generation, and React Query integration.


See Also

Source: SKILL.md on GitHub

2 warnings16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The skill provides comprehensive guidance, templates, reference scripts, and architectural configurations for developing secure Electron applications with React. All components adhere strictly to security standards, and all external tools, scripts, and configurations are handled using safe practices without any malicious or suspicious patterns.

  • Socket16d

    1 alert: gptSecurity

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer7mo

    35/35 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 075cb8f. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Dormantupdated 8 months ago
Other metadata
compatibility
Requires Deno for analysis scripts. Applicable to any Electron project using TypeScript and React.
metadata
{
  "author": "agent-skills",
  "version": "1.0",
  "domain": "development",
  "type": "utility",
  "mode": "assistive"
}

README badge

README badge for jwynia/agent-skills/electron-best-practices