Migration can be incremental. Convert one channel group at a time, keep its existing physical names when compatibility matters, and remove the old preload wrapper only after renderer callers use the generated bridge.
A typical Electron application repeats a channel name and its types in three places:
// main
ipcMain.handle("profile:get", (_event, id: string) => profileService.get(id));
// preload
contextBridge.exposeInMainWorld("api", {
getProfile: (id: string) => ipcRenderer.invoke("profile:get", id),
});
// renderer declaration
interface Window {
api: { getProfile(id: string): Promise<Profile> };
}
The renderer declaration can drift from the actual handler, and a generic preload wrapper can accidentally expose channels that were never meant to be public.
Move the handler into a *.ipc.ts module:
// main/ipc/profile.ipc.ts
import { defineIpcModule, handle } from "electron-ipc-module";
export const registerProfileIpc = defineIpcModule("profile", {
get: handle((_event, id: string) => profileService.get(id)),
});
Load it in the main process:
const ipc = createIpcContainer();
await ipc.load("profile", registerProfileIpc);
Generate and expose the bridge:
ipcBridge({
ipcDir: "./main/ipc",
outFile: "./main/generated/ipc-bridge.ts",
tsconfig: "./tsconfig.preload.json",
expose: "api",
});
The renderer call becomes window.api.profile.get(id). Its argument and return
types now come from the handler rather than a duplicated declaration.
defineIpcModule(prefix, channels) joins the prefix and key with a colon. To
retain profile:get, use prefix "profile" and key "get". An empty prefix
retains an unnamespaced channel:
defineIpcModule("", {
"legacy-channel": handle(callback),
});
Renderer method names are normalized to JavaScript identifiers. For example,
get-all becomes getAll. Review the committed generated bridge during each
migration so API renames are explicit.
| Existing Electron registration | Module helper | Generated renderer method |
|---|---|---|
ipcMain.handle |
handle |
Promise-returning method |
ipcMain.handleOnce |
handleOnce |
Promise-returning method |
ipcMain.on |
listen |
Fire-and-forget method |
ipcMain.once |
listenOnce |
Fire-and-forget method |
One-shot channels are process-scoped because ipcMain is global. The first
window consumes them. Do not migrate a per-window initialization call to
handleOnce; use handle and track initialization by event.sender instead.
First declare the event map and bind the helpers to it:
type ProfileEvents = {
updated: [profile: { id: string; name: string }];
};
const { handle } = createIpcHelpers<ProfileEvents>();
export const registerProfileIpc = defineIpcModule(
"profile",
{
save: handle(async (event, input: ProfileInput) => {
const profile = await profileService.save(input);
event.sender.send("updated", profile);
return profile;
}),
},
{ eventPrefix: true },
);
export const profileEvents = defineIpcEvents<ProfileEvents>();
The generated listener is window.api.profile.onUpdated(callback). Store and
call its returned unsubscribe function when the owning view unmounts.
Events from jobs, timers, or file watchers can move to createIpcEmitter.
Pass the module register function so it inherits the same eventPrefix:
const profileEmitter = createIpcEmitter<ProfileEvents>(registerProfileIpc);
profileEmitter.emitTo(window.webContents, "updated", profile);
createIpcHelpers<ProfileEvents>() already tells the generator about the event
map, so the defineIpcEvents export above is only needed for a module that
emits events without binding helpers to them. Declaring both is harmless —
duplicate event names are collapsed.
Types do not validate messages from a compromised renderer. A migration is a
good time to identify privileged channels and add authorize and validate
before moving filesystem, network, shell, or credential operations behind the
new bridge. See securing IPC channels.
For each migrated module:
electron-ipc-module check and the renderer type check.ipcMain registration, preload wrapper, and handwritten
renderer declaration.Do not keep both registrations for the same physical channel. Electron will reject duplicate handlers, and the IPC container also reports collisions between loaded modules.