# How to Implement a Typed IPC API Between Electron and Renderer Using Preload Scripts

> Learn to implement a typed IPC API between Electron and renderer using preload scripts. Expose strongly-typed objects with contextBridge for safe, IntelliSense-rich communication.

- Repository: [lightningpixel/modly](https://github.com/lightningpixel/modly)
- Tags: how-to-guide
- Published: 2026-08-21

---

**A typed IPC API is built by exposing a strongly-typed JavaScript object via `contextBridge.exposeInMainWorld` in a preload script, allowing the renderer to call main-process handlers through whitelisted channels while maintaining full TypeScript IntelliSense.**

In modern Electron applications, security best practices require disabling `nodeIntegration` and isolating the renderer process from raw Node.js APIs. The **Modly** codebase demonstrates how to bridge this gap by implementing a type-safe IPC layer using preload scripts and the `contextBridge` API. This pattern ensures that renderer code can invoke main-process functionality—such as file dialogs or window controls—without sacrificing sandbox security or compile-time type checking.

## Architecture Overview

Electron’s **Context Isolation** feature creates a separate JavaScript context for preload scripts, preventing the renderer from directly accessing `ipcRenderer` or other privileged modules. To expose safe, typed methods to the renderer, Modly uses a three-layer architecture:

1. **API Definition Layer** – A TypeScript wrapper that maps method signatures to IPC channels.
2. **Bridge Injection Layer** – The preload script entry point that uses `contextBridge.exposeInMainWorld` to inject the API.
3. **Handler Registration Layer** – The main process file that implements the corresponding `ipcMain.handle` and `ipcMain.on` listeners.

This structure guarantees that only explicitly defined methods are available on `window.electron`, creating a minimal attack surface.

## Step 1 – Define the Typed IPC Wrapper

Create [`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts) to define the contract between the renderer and main process. This file imports `ipcRenderer` and `webFrame` from Electron, then returns a plain object where each property represents a namespaced set of IPC methods.

```typescript
// electron/preload/electron-api.ts
export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFrameLike) {
  return {
    // Window controls
    window: {
      minimize: () => ipcRenderer.send('window:minimize'),
      maximize: () => ipcRenderer.send('window:maximize'),
      close:    () => ipcRenderer.send('window:close'),
      isMaximized: () => ipcRenderer.invoke('window:isMaximized') as Promise<boolean>,
      onMaximizeChange: (cb: (isMaximized: boolean) => void) => {
        ipcRenderer.on('window:maximizeChanged', (_e, v) => cb(v as boolean))
      },
      offMaximizeChange: () => ipcRenderer.removeAllListeners('window:maximizeChanged'),
    },

    // File-system dialogs
    fs: {
      selectImage: (): Promise<string | null> =>
        ipcRenderer.invoke('fs:selectImage') as Promise<string | null>,
      saveModel: (defaultName: string): Promise<string | null> =>
        ipcRenderer.invoke('fs:saveModel', defaultName) as Promise<string | null>,
    },

    // Model management
    model: {
      download: (args: {
        repoId: string; modelId: string; skipPrefixes?: string[]; includePrefixes?: string[];
      }): Promise<{ success: boolean; error?: string }> =>
        ipcRenderer.invoke('model:download', args) as Promise<any>,
      onProgress: (cb: (data: any) => void) => {
        ipcRenderer.on('model:downloadProgress', (_e, d) => cb(d))
      },
      offProgress: () => ipcRenderer.removeAllListeners('model:downloadProgress')
    },

    // …other sections (settings, cache, extensions, etc.)
  }
}

```

Each method uses **TypeScript type assertions** (e.g., `as Promise<string | null>`) to ensure the renderer receives correctly typed return values. The handler signatures in this file must mirror those registered in the main process.

## Step 2 – Expose the API via Context Bridge

In [`electron/preload/index.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/index.ts), import the wrapper function and invoke `contextBridge.exposeInMainWorld`. This injects the API as a read-only property on the global `window` object, accessible to the renderer as `window.electron`.

```typescript
// electron/preload/index.ts
import { contextBridge, ipcRenderer, webFrame } from 'electron'
import { createElectronApi } from './electron-api'

// Expose a **typed** API to the renderer via `window.electron`
contextBridge.exposeInMainWorld('electron', createElectronApi(ipcRenderer, webFrame))

```

Because `contextBridge` runs in a privileged but isolated context, the renderer cannot access `ipcRenderer` directly—it can only invoke the whitelisted methods exposed through `createElectronApi`.

## Step 3 – Register IPC Handlers in the Main Process

In [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts), register the corresponding implementations using `ipcMain.handle` for request-response channels and `ipcMain.on` for fire-and-forget events.

```typescript
// electron/main/ipc-handlers.ts (conceptual example based on Modly implementation)
import { ipcMain, dialog, BrowserWindow } from 'electron'

export function registerIpcHandlers() {
  // Window management
  ipcMain.on('window:minimize', (event) => {
    const win = BrowserWindow.fromWebContents(event.sender)
    win?.minimize()
  })

  ipcMain.handle('window:isMaximized', async (event) => {
    const win = BrowserWindow.fromWebContents(event.sender)
    return win?.isMaximized() ?? false
  })

  // File system operations
  ipcMain.handle('fs:selectImage', async () => {
    const result = await dialog.showOpenDialog({
      properties: ['openFile'],
      filters: [{ name: 'Images', extensions: ['jpg', 'png', 'gif'] }]
    })
    return result.filePaths[0] ?? null
  })

  ipcMain.handle('fs:saveModel', async (event, defaultName: string) => {
    const result = await dialog.showSaveDialog({
      defaultPath: defaultName,
      filters: [{ name: 'Model', extensions: ['json', 'bin'] }]
    })
    return result.filePath ?? null
  })

  // Model downloads
  ipcMain.handle('model:download', async (event, args: {
    repoId: string;
    modelId: string;
    skipPrefixes?: string[];
    includePrefixes?: string[];
  }) => {
    // Implementation details...
    return { success: true }
  })
}

```

The **channel names** and **method signatures** must exactly match those defined in [`electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron-api.ts) to preserve end-to-end type safety.

## Step 4 – Consume the API in Renderer Code

The renderer can access the API through the global `window.electron` object. For full TypeScript support, declare the type definitions in [`src/shared/types/electron.d.ts`](https://github.com/lightningpixel/modly/blob/main/src/shared/types/electron.d.ts).

```typescript
// Renderer usage (e.g., React component)
async function openImage() {
  const path = await window.electron.fs.selectImage()
  if (path) {
    console.log('User selected image:', path)
  }
}

function minimizeWindow() {
  window.electron.window.minimize()
}

// TypeScript declaration file reference
// src/shared/types/electron.d.ts

```

Because the API is fully typed, IDEs provide IntelliSense for method names, argument types, and return values. TypeScript will emit compilation errors if you pass incorrect arguments to `window.electron.model.download()` or mishandle the promise returned by `window.electron.fs.saveModel()`.

## Security and Type Safety Guarantees

The preload script architecture in Modly provides several critical security benefits:

- **Privileged Isolation** – The preload script runs in an isolated context with access to Node.js and Electron APIs, while the renderer runs in a pure browser sandbox with `nodeIntegration` disabled.
- **Explicit Whitelisting** – Only methods explicitly defined in `createElectronApi` are exposed; any new IPC channel must be added to both [`electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron-api.ts) and [`ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/ipc-handlers.ts), making the attack surface auditable.
- **Type Enforcement** – TypeScript interfaces ensure that refactors in the main process automatically trigger type errors in the renderer, preventing runtime channel mismatches.

## Summary

- **Create an API wrapper** in [`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts) that accepts `ipcRenderer` and returns typed method objects mapping to IPC channels.
- **Expose via context bridge** in [`electron/preload/index.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/index.ts) using `contextBridge.exposeInMainWorld('electron', createElectronApi(...))`.
- **Register handlers** in [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts) using `ipcMain.handle` and `ipcMain.on` with matching channel names and signatures.
- **Consume globally** in renderer code through `window.electron`, leveraging TypeScript declarations in [`src/shared/types/electron.d.ts`](https://github.com/lightningpixel/modly/blob/main/src/shared/types/electron.d.ts) for compile-time safety.
- This pattern isolates the renderer from Node.js while maintaining a fully typed, IDE-friendly API surface.

## Frequently Asked Questions

### How does contextBridge improve security compared to directly exposing ipcRenderer?

The `contextBridge` API creates a one-way proxy that exposes only specific, whitelisted functions to the renderer. Directly exposing `ipcRenderer` would allow malicious scripts to send arbitrary IPC messages to any channel. By contrast, Modly’s wrapper in [`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts) restricts the renderer to predefined methods like `window.electron.window.minimize()`, effectively eliminating the risk of arbitrary process communication.

### Can I use this pattern with React or Vue in the renderer process?

Yes. Because the API is attached to the global `window` object, it is framework-agnostic. In a React component, you can call `await window.electron.fs.selectImage()` inside an event handler or `useEffect` hook. For optimal TypeScript support, reference the type declarations in [`src/shared/types/electron.d.ts`](https://github.com/lightningpixel/modly/blob/main/src/shared/types/electron.d.ts) or extend the global `Window` interface in your project.

### What happens if the channel names in the preload script and main process do not match?

TypeScript will not catch runtime string mismatches in channel names unless you use string literal types. If a channel name differs between [`electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron-api.ts) and [`ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/ipc-handlers.ts), the renderer’s `ipcRenderer.invoke` call will return a rejected promise with an error stating that no handler is registered for that channel. Modly mitigates this by co-locating channel definitions or using shared constants for channel names.

### Is it possible to pass complex objects or callbacks through the typed IPC API?

You can pass serializable objects as arguments and return values, as Electron’s IPC uses the Structured Clone Algorithm. However, functions and callbacks cannot be passed directly. Instead, use the pattern shown in [`electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron-api.ts) for event listeners: register a callback wrapper in the preload that invokes `ipcRenderer.on`, then provide `off` methods (like `offMaximizeChange`) to remove listeners and prevent memory leaks in the main process.