The shared contract

One defineApi call describes every request/response method and every event. It is the only file that has to change when the IPC surface changes.

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

export interface User {
  id: string
  name: string
}

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>(),
  },
})

The three builders

BuilderArgumentsMeaning
defineInvoke<Args, Return>()A tuple of arguments, and the resolved valueRequest/response method
defineEvent<Payload>()The pushed payloadOne-way main → renderer event
defineApi({ invoke, events })A schemaStamps channel paths onto the tokens

invoke and events are both required. Pass {} for either if that side is empty — this is what keeps api.invoke.foo from widening to foo | undefined at every use site.

How the types travel

A token is an ordinary object at runtime. Its type is where the information lives:

interface InvokeToken<Args, Return> {
  readonly __kind: 'invoke'
  readonly __path: string   // filled at runtime by defineApi
  readonly __args: Args     // phantom, erased at runtime
  readonly __return: Return // phantom, erased at runtime
}

defineApi walks the schema and writes the channel path onto each token, derived from its key — getUser becomes invoke:getUser. The schema’s type is returned unchanged, so api.invoke.getUser keeps its literal type InvokeToken<[string], User>.

The __args, __return and __payload fields are phantom: they exist only in the type system and are erased at runtime. Every downstream consumer — registerMain, exposeBridge, useInvoke — extracts them with conditional types:

export type InvokeArgs<T> = T extends InvokeToken<infer A, any> ? A : never
export type InvokeReturn<T> = T extends InvokeToken<any, infer R> ? R : never
export type EventPayload<T> = T extends EventToken<infer P> ? P : never

That is the whole trick. Define once, infer everywhere.

Naming

Event names are conventionally namespace:action (user:updated), but nothing enforces that — any string key works. It is the key, not the type, that becomes the channel name, so two tokens with the same key would collide.

Next

Main process — registering the handlers this contract describes.