Type-safe Electron IPC with hook-style APIs. Define the contract once, get full type inference across main / preload / renderer — no duplicated types.
📖 Documentation & live demo — the full guide, the API reference, and every snippet checked against this repository.
- 🔒 End-to-end type inference — one
defineApi(), every arg / return / payload is inferred everywhere via type tokens. - 🪝 Hook-style calls —
useInvoke()/useEvent()return reactive signals, framework-agnostic (drop into React, Vue, Solid, or vanilla). - ⚡ Tiny — a minimal signal primitive, no framework runtime dependency.
- 🧱 Structured —
invoke(async request/response),sendSync(blocking request/response), andevents(main → renderer).
Import paths:
registerMainandexposeBridgeare not exported from the package root — the root entry would otherwise pullelectroninto renderer bundles. Import them from their subpath entries:import { registerMain } from '@cc-heart/electron-use-ipc/main' import { exposeBridge } from '@cc-heart/electron-use-ipc/preload'If you previously imported them from the root, see CHANGELOG.md.
npm install @cc-heart/electron-use-ipc// shared/api.ts
import { defineApi, defineEvent, defineInvoke, defineSendSync } from '@cc-heart/electron-use-ipc'
export interface User { id: string; name: string }
export const api = defineApi({
// Asynchronous calls — the renderer gets a Promise
invoke: {
getUser: defineInvoke<[string], User>(),
listUsers: defineInvoke<[], User[]>(),
createUser: defineInvoke<[string], User>(),
getWindowTitle: defineInvoke<[number], string>(),
},
// Synchronous calls — blocking, see the warning below
sendSync: {
isNativeDarkMode: defineSendSync<[], boolean>(),
},
events: {
'user:updated': defineEvent<User>(),
'server:shutdown': defineEvent<void>(),
},
})invoke, sendSync and events are all optional; defineApi fills the ones
you omit with {} while keeping the exact keys of the ones you supplied.
import { nativeTheme } from 'electron'
import { registerMain } from '@cc-heart/electron-use-ipc/main'
import { api } from '../shared/api'
const main = registerMain(api, {
invoke: {
getUser: async (id) => db.users.get(id), // (id: string) => User
listUsers: async () => db.users.all(),
createUser: async (name) => {
const u = { id: crypto.randomUUID(), name }
main.emit(api.events['user:updated'], u) // payload typed: User
return u
},
},
invokeWithSenderId: {
// `senderId` is the id of the originating window's webContents.
// The second argument is the one declared by the token ([number]).
getWindowTitle: async (senderId, version) => `Window ${senderId} v${version}`,
},
sendSync: {
// Synchronous handler: must NOT be async.
isNativeDarkMode: () => nativeTheme.shouldUseDarkColors,
},
// Also available: invokeWithEvent, sendSyncWithEvent, sendSyncWithSenderId
})
main.emit(api.events['server:shutdown']) // void event: no payload
main.emitTo(win, api.events['user:updated'], u) // send to one window onlyHandler registration rules — a typo used to be silently ignored. Now:
- Each schema method may be registered in exactly one group. Registering the
same method in two groups (e.g.
invokeandinvokeWithEvent) throws at startup. - A handler name that is not in the schema throws at startup (and is a TypeScript error as well).
- Duplicate detection is process-wide: a second
registerMain()cannot attach another listener to a channel and silently overwrite its reply. Calldispose()on the earlier result before re-registering. - Registration is atomic: if anything throws, every handler and listener this call installed is rolled back, so fixing the mistake and retrying just works.
- Methods with no handler produce a startup
console.warninstead of failing only when a renderer calls them. AsendSyncmethod additionally gets a fallback reply, so a missing handler can never hang the renderer.
import { exposeBridge } from '@cc-heart/electron-use-ipc/preload'
import { api } from '../shared/api'
exposeBridge(api) // -> window.apiimport { invoke, sendSync, useInvoke, useEvent } from '@cc-heart/electron-use-ipc/renderer'
import { api } from '../shared/api'
const { data, loading, call } = useInvoke(api.invoke.getUser)
// call: (id: string) => Promise<User> ✅ inferred
// data: Signal<User | undefined> ✅ inferred
await call('1')
console.log(data.get()?.name)
const updated = useEvent(api.events['user:updated'])
// updated: Signal<User | undefined> (undefined until the first event)
updated.subscribe((u) => { if (u) console.log(u.name) })
const title: string = await invoke(api.invoke.getWindowTitle, 69)
const isDark: boolean = sendSync(api.sendSync.isNativeDarkMode)A token is not callable — always pass it to invoke() / sendSync() /
useInvoke() / useEvent().
| Function | Side | Purpose |
|---|---|---|
defineApi(schema) |
shared | Stamp channel paths onto tokens; preserve literal types |
defineInvoke<Args, Return>() |
shared | Type token for an async request/response method |
defineSendSync<Args, Return>() |
shared | Type token for a synchronous request/response method |
defineEvent<Payload>() |
shared | Type token for a main→renderer event |
registerMain(api, handlers) |
main | Register handlers; returns { emit, emitTo, dispose } |
exposeBridge(api, name?) |
preload | Expose typed window[name] via contextBridge |
useInvoke(token, opts?) |
renderer | Hook → { data, error, loading, call } signals |
useEvent(token) |
renderer | Hook → Signal<Payload | undefined> |
invoke(token, ...args) |
renderer | One-shot typed async call → Promise<Return> |
sendSync(token, ...args) |
renderer | One-shot typed synchronous call → Return |
There is no useSendSync: a call you want to queue up and observe repeatedly is
never a call that must be synchronous.
sendSync maps to ipcRenderer.sendSync, which blocks the entire renderer
process until the main process replies. Electron's own docs call it a
"last resort". Prefer invoke unless await is technically impossible — for
example reading the native theme before ready-to-show to avoid a flash.
Because a synchronous reply has no equivalent of promise rejection, a handler
that throws is turned into a small error envelope and rethrown by sendSync()
in the renderer. A sendSync method with no registered handler fails at startup
with a warning and will throw when called; never leave one unhandled.
A token is a runtime-light object carrying a channel path, whose type is
parameterized by Args / Return / Payload:
interface InvokeToken<
Args extends AnyArgs = [],
Return = void,
> {
readonly __kind: 'invoke'
readonly __path: string
readonly __args: Args
readonly __return: Return
}defineApi walks the schema and stamps __path from each key, while the
TypeScript compiler keeps the literal schema type — so api.invoke.getUser
retains InvokeToken<[string], User>, and a misspelled key is a compile error.
From that point every consumer (registerMain, exposeBridge, useInvoke)
extracts args/return via conditional types (InvokeArgs, InvokeReturn, etc.).
Define once, infer everywhere.
invoke methods map to ipcRenderer.invoke; the renderer receives a Promise.
sendSync methods map to ipcRenderer.sendSync; the renderer receives the value
directly, not a Promise.
There are three variants for handlers specified in registerMain:
invoke/sendSync— just the declared arguments.invokeWithEvent/sendSyncWithEvent— the full event object is prepended.invokeWithSenderId/sendSyncWithSenderId— onlyevent.sender.idis prepended.
Internally, invoke calls receive an IpcMainInvokeEvent, while sendSync
calls receive an IpcMainEvent. Most of the time you don't want the whole event
object, only the sending window — that is what the WithSenderId variants are
for. This is mainly useful in multi-window applications: capture
window.webContents.id on ready-to-show and match it against the sender id in
your handlers.
The core is framework-agnostic and returns Signals. Signal<T> always holds a
T: get() returns T and subscribe receives T. Signals that begin empty
(such as useEvent / useInvoke().data) are typed Signal<T | undefined>.
// React adapter (example)
import { useSyncExternalStore } from 'react'
export function useSignalValue<T>(s: Signal<T>): T {
return useSyncExternalStore(
(cb) => s.subscribe(cb),
s.get,
)
}
// const user = useSignalValue(useInvoke(api.invoke.getUser).data)npm run typecheck # tsc --noEmit, includes examples/neg-check.ts
npm run test:negative # negative type tests (@ts-expect-error must all be used)
npm run test:runtime # node:test against an ipcMain/ipcRenderer mock
npm run test # all of the above
npm run build # tsup ESM + d.tsMIT