Johny Rokita

Making Electron IPC Boring

June 18, 2026 (yesterday)

I love using Electron to build apps. It makes life simple, and it's actually not difficult to build a performant, native-feeling app if you follow a few basic rules.

But there is one part that never felt simple to me: the separation between the Node process and the renderer process, and the communication between them.

I tried many different libraries, and even tried building my own version of a REST style api for Electron, but over IPC mimicking fetch, etc. I wanted the process to feel easier, but nothing ever clicked.

Then recently I started building myself an email client in Electron, something I could navigate like Superhuman but without paying the subscription, and where i could add my own features, and the idea finally came to me: what if I made a simple code generator that exposes the functions I somehow mark in code?

Normally, even a tiny IPC call means writing the same idea in a few different places, and then adding types around it so the renderer knows what exists.

// shared/api.ts
export type AppApi = {
  getNews: () => Promise<string>;
};
// main/ipc.ts
import { ipcMain } from "electron";

ipcMain.handle("getNews", async () => {
  const response = await fetch("https://example.com/news");

  return response.text();
});
// preload.ts
import { contextBridge, ipcRenderer } from "electron";
import type { AppApi } from "./shared/api";

const api: AppApi = {
  getNews: () => ipcRenderer.invoke("getNews"),
};

contextBridge.exposeInMainWorld("api", {
  getNews: api.getNews,
});
// renderer/global.d.ts
import type { AppApi } from "../shared/api";

declare global {
  interface Window {
    api: AppApi;
  }
}
// renderer/news.ts
const news = await window.api.getNews();

And this is still the easy version. Once arguments, return types, errors, or more methods show up, you have to keep the shared type, the channel name, the main handler, the preload bridge, and the renderer usage in sync. None of that is hard, but it's very repetitive and slows down development a lot, even for coding agents.

So I built a tool to fix it. It's called electron-expose, and it works like this:

// routes/news.ts
export class NewsRoutes {
  @expose("getNews")
  async getNews(): Promise<string> {
    const response = await fetch("https://example.com/news");

    return response.text();
  }
}

This exposes the getNews function to the renderer process, with types, as window.api.getNews().

That's it. No setting up IPC callbacks by hand, no creating preload types manually, no repeating the same shape in three different places.

I know a lot of people don't like decorators, but I think this is an appropriate use for them. They're not being used at runtime. They're just build-time markers for the electron-expose generate command, which generates the IPC logic and type files for us.

It's simple on purpose. I didn't want built-in schema validation or anything like that, because those things can already be handled inside the exposed functions with whatever the user prefers, for example Zod.

Electron Expose has one purpose: to make IPC boring.

If you have any ideas or suggestions, feel free to create an issue on the GitHub page and i'll gladly take a look at it.

NPM (electron-expose): npmjs.com/package/electron-expose

GitHub: github.com/johnyrokita/electron-expose