v0.0.1 · MIT · Electron >=20

Type-safe IPC for Electron. Define once. Infer everywhere.

Write the contract once in shared code and every arg, return value and event payload is inferred across main, preload and renderer — no duplicated types, no casts.

Read the docs
Runtime dependencies
0
Renderer entry · brotli
505 B
Runtime exports
11
Contract → processes
1 → 3

Published package: 50,097 B across 20 files · all figures measured from dist/ at build time

01 / The contract

Write the shape down once.

A token carries its channel path at runtime and its types at compile time. defineApi stamps the paths; TypeScript keeps the literals. This is the only place your IPC surface is described.

examples/shared/api.tsShared
import { defineApi, defineEvent, defineInvoke } from '@cc-heart/electron-use-ipc'

export interface User {
  id: string
  name: string
}

/**
 * Single source of truth: define the contract once.
 * Every arg / return / payload type flows from here to main, preload, renderer.
 */
export const api = defineApi({
  invoke: {
    getUser: defineInvoke<[string], User>(),
    listUsers: defineInvoke<[], User[]>(),
    createUser: defineInvoke<[string], User>(),
  },
  events: {
    'user:updated': defineEvent<User>(),
    'server:shutdown': defineEvent<void>(),
  },
})
examples/main.tsMain
import { registerMain } from '@cc-heart/electron-use-ipc/main'
import { api, type User } from './shared/api'

const users = new Map<string, User>()

// `main` is captured by closures below; declare first so handlers can emit.
const main = registerMain(api, {
  invoke: {
    // (id: string) => Promise<User>  — inferred from the token
    getUser: async (id) => {
      const u = users.get(id)
      if (!u) throw new Error(`no user ${id}`)
      return u
    },
    // () => Promise<User[]>
    listUsers: async () => [...users.values()],
    // (name: string) => Promise<User>
    createUser: async (name) => {
      const u: User = { id: crypto.randomUUID(), name }
      users.set(u.id, u)
      // typed event broadcast: payload must be User
      main.emit(api.events['user:updated'], u)
      return u
    },
  },
})

// void event: no payload allowed
main.emit(api.events['server:shutdown'])

main.dispose()

The handler's parameters are not annotated — they are inferred from the token. Change getUser's return type in shared/api.ts and this file stops compiling until it matches.

02 / The consumers

Every other side infers from it.

Preload forwards tokens without knowing what they mean. The renderer gets call signatures, return types and event payloads straight from the same definition.

examples/preload.tsPreload
import { exposeBridge } from '@cc-heart/electron-use-ipc/preload'
import { api } from './shared/api'

// exposes window.api with the typed bridge
exposeBridge(api)
examples/renderer.tsRenderer
import { useEvent, useInvoke } from '@cc-heart/electron-use-ipc/renderer'
import { api } from './shared/api'

/* ---- request / response hook ---- */
const { data, loading, call } = useInvoke(api.invoke.getUser)
//    call: (id: string) => Promise<User>     ✅ inferred
//    data: Signal<User | undefined>          ✅ inferred

call('1').then(() => {
  const u = data.get() // User | undefined
  console.log(u?.name)
})

data.subscribe((u) => {
  // u: User | undefined
})

/* ---- immediate call (args still typed) ---- */
const list = useInvoke(api.invoke.listUsers, { immediate: [] })
//    list.data: Signal<User[] | undefined>

/* ---- event hook ---- */
const updated = useEvent(api.events['user:updated'])
//    updated: Signal<User | undefined>       ✅ inferred

updated.subscribe((u) => {
  if (u) console.log('updated:', u.name)
})

/* ---- compile-time type checks (uncomment to see errors) ---- */
// call(123)                              // ❌ Argument of type 'number'...
// main.emit(api.events['user:updated'])  // ❌ void-typed event still works, but
//   // a non-void event without payload errors. (void events allow no-arg emit)
// updated.subscribe((u: number) => {})   // ❌ u is User | undefined

No casts, no as User, no duplicated interfaces. call('1') is typed (id: string) => Promise<User> purely from api.invoke.getUser.

03 / The difference

Instead of casts, compile errors.

Hand-wired IPC has three places to drift and a cast at the end to hide it. With a contract, a wrong argument is a build failure rather than a runtime surprise.

site/snippets/plain-ipc.tsBefore
/**
 * BEFORE — the same feature wired by hand.
 *
 * This file is illustrative content for the landing page, not
 * compiled. It is kept as a real file so it highlights from
 * source like every other snippet.
 *
 * Three files, three chances to drift: the channel name is a
 * bare string, the payload types are restated at each boundary,
 * and nothing connects them.
 */

// ── main.ts ─────────────────────────
import { ipcMain } from 'electron'

ipcMain.handle('get-user', async (_event, id) => {
  // `id` is `any` here — the cast is a promise, not a check
  return db.users.get(id as string)
})

// ── preload.ts ──────────────────────
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('api', {
  getUser: (id: string) => ipcRenderer.invoke('get-user', id),
  onUserUpdated: (cb: (user: User) => void) =>
    ipcRenderer.on('user:updated', (_e, user) => cb(user)),
})

// ── renderer.ts ─────────────────────
interface User {
  id: string
  name: string
}

// cast it and hope
const user = (await window.api.getUser('1')) as User
// `any` all the way down
window.api.onUserUpdated((u: any) => console.log(u.name))
examples/neg-check.tsAfter
// Negative test: each @ts-expect-error must be "used" (i.e. a real error occurs).
// If tsc passes, the types are BOTH correct (positive cases) AND strict
// (these bad cases really error). If an @ts-expect-error becomes unused,
// that line leaked `any`.
import { useEvent, useInvoke } from '@cc-heart/electron-use-ipc/renderer'
import { api } from './shared/api'

const { call } = useInvoke(api.invoke.getUser)
// @ts-expect-error number is not assignable to string
call(123)

const updated = useEvent(api.events['user:updated'])
// @ts-expect-error payload is User | undefined, not number
updated.subscribe((u: number) => {})

The right-hand file is a real test in this repository. Each @ts-expect-error must be used — if a bad call ever stopped erroring, tsc would fail on the unused directive.

04 / Properties

Small, and framework-agnostic.

The renderer entry imports neither Electron nor a UI framework. It exposes signals, so binding it to React, Vue, Solid or plain DOM is a few lines each.

05 / Reference

The whole surface.

Eleven runtime exports and no dependencies. Nine of them are the API you will actually call — the other two resolve channel names, which you only need if you are writing your own transport.

FunctionSignatureSidePurpose
defineApidefineApi(schema)sharedStamps channel paths onto tokens while preserving literal types.
defineInvokedefineInvoke<Args, Return>()sharedType token for a request/response method.
defineEventdefineEvent<Payload>()sharedType token for a one-way main → renderer event.
registerMainregisterMain(api, { invoke })mainRegisters handlers; returns { emit, dispose }.
exposeBridgeexposeBridge(api, name?)preloadExposes a typed window[name] through contextBridge.
useInvokeuseInvoke(token, opts?)rendererHook returning { data, error, loading, call } signals.
useEventuseEvent(token)rendererHook returning Signal<Payload | undefined> for pushed events.
invokeinvoke(token, ...args)rendererOne-shot typed call, no reactive state.
signalsignal(initial)coreThe framework-agnostic reactive primitive behind every hook.