How to Implement a Typed IPC API Between Electron and Renderer Using Preload Scripts
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:
- API Definition Layer – A TypeScript wrapper that maps method signatures to IPC channels.
- Bridge Injection Layer – The preload script entry point that uses
contextBridge.exposeInMainWorldto inject the API. - Handler Registration Layer – The main process file that implements the corresponding
ipcMain.handleandipcMain.onlisteners.
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 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.
// 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, 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.
// 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, register the corresponding implementations using ipcMain.handle for request-response channels and ipcMain.on for fire-and-forget events.
// 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 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.
// 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
nodeIntegrationdisabled. - Explicit Whitelisting – Only methods explicitly defined in
createElectronApiare exposed; any new IPC channel must be added to bothelectron-api.tsandipc-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.tsthat acceptsipcRendererand returns typed method objects mapping to IPC channels. - Expose via context bridge in
electron/preload/index.tsusingcontextBridge.exposeInMainWorld('electron', createElectronApi(...)). - Register handlers in
electron/main/ipc-handlers.tsusingipcMain.handleandipcMain.onwith matching channel names and signatures. - Consume globally in renderer code through
window.electron, leveraging TypeScript declarations insrc/shared/types/electron.d.tsfor 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 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 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 and 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 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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →