Adding TypeSafety to Electron IPC with TypeScript

Kishan Nirghin
5 min readJun 5, 2022

The default Electron IPC (Inter Process Communication) protocols don’t offer support for strong typing. This blogpost will cover some techniques to cope with this limitation and show examples of how to add strong typing support to the IPC channels.

Inter Process Communication Channels

Roughly put (and what will hold for most use-cases) is that each call to IpcRenderer.invoke triggers the handler that was setup using IpcMain.handle. Since the relationship between the invoke and handle on the same channel is tightly coupled, it would be helpful if TypeHints would be available and shown during development.

Overview of Electron IPC channels

For example: If there exists a handler on a channel that requires 2 parameters and returns an integer, TypeScript should be able to indicate both the required parameters and the return type while calling the invoke function (and show a warning if the constraints aren’t met).

// main.ts
IpcMain.handle("add", (ev, n1: number, n2: number): number => n1+n2
// preload.ts
const result = await IpcRenderer.invoke("add", 1, 1)
// (should display) error: call doesn't fit pattern of handler
const result = await IpcRenderer.invoke("add", 1, "1")

By default the return type of IpcRenderer.invoke is set to Promise<any>, however in the case of the add channel we’d rather see a return type of Promie<number>.

So.. Let’s change that and add some typing.

Add generic types to IPC methods

For the example above the best case scenario would give us type-hints for both the required arguments and the return type of the handler function¹.

Strong typing an already defined function with weak types is easy to achieve by creating a strong typed wrapper.

function invoke<P extends any[], R>(channel: string, ...args: P) {
return ipcRenderer.invoke(channel, ...args) as Promise<R>
}

The example above wraps the original ipcRenderer.invoke call in a Higher-Order-Function that contains strong types. The newly created invoke call allows the user to specify the type of the expected return type and the type of the handler parameters.

In the new invoke function the generic P represents the types of the parameters (as an array) and R represents the type of the return type.

Meaning that we can strong-type a call to the ‘add’ channel using the following syntax:

const result = await invoke<[number, number], number>("add", 1, 1)// Will show a type error
const result = await invoke<[number, number], number>("add", 1, "1")

The generic types indicate that there are 2 required parameters with type number and number respectively and that the return type is number .

Whereas the above works perfectly fine, it still puts more responsibility in hands of the developer than necessary. In our situation the handler of the ‘add’ channel is known, therefore we’re able to deduce the types of the parameters and the return type statically.

Extract type information from the handler

There are 2 convenient TypeScript tricks we can apply to extract type information of a function. Using ReturnType<T> and Parameters<T> one can easily extract the return type and the parameter types of a function respectively.

// handler function definition
function addChannelHandler(
_ev: IpcMainInvokeEvent,
n1: number,
n2: number
) {
return n1+n2;
}
// Dynamically extract the returnType of a function
invoke<[], ReturnType(typeof addChannelHandler)>("add", 1, 1)

In case of the ipcMain.handle handler functions, the first argument by default is the IpcMainInvokeEvent which contains some meta-data. Running Parameters<typeof addChannelHandler> would yield [IpcMainInvokeEvent, number, number] which is suboptimal.

Rather we’d like to have all parameters of the handler except for the first. To achieve that lets define a new TypeScript type: Params<H> .

type Params<H> = H extends (ev: IpcMainInvokeEvent, ...rest: infer P
) => any ? P : never;

The newly created type will return the types of the parameters with exception of the first ipcMainInvokeEvent parameter.

invoke<Params<typeof addChannelHandler>, ReturnType(typeof addChannelHandler)>("add", 1, 1)

The above is already much of an improvement. Now each invoke method only has to be defined once, and it can be used safely as it is and remains tightly coupled to the handler function. If the blueprint of the handler function changes, the invoke call will adapt.

Now if you’re like me, the above feels a bit verbose. This can be improved by creating yet another Higher-Order-Function which extracts Params and ReturnType based on a generic. However, an alternative that I prefer is to use the @kjn/electron-typesafe-ipc npm package.

Using the @kjn/electron-typesafe-ipc package

As described by the package-docs, when dealing with IPC going from the renderer process to the main process, each channel should be its own entity, responsible only for 1 thing, and be logically separated from the rest of the channels. Making this separation flows naturally with the 2 introduced replacement functions of the package: invokeIpcChannel and handleIpcChannel .

const addChannelHandler = {
name: "add",
handler: (_ev: IpcMainInvokeEvent, n1: number, n2: number): number => n1 + n2
}

The idea is to have an object representing all data of each channel. Since the channels only contain a name and a handler function these objects remain fairly small.

Now this object can be used to register the handler for the channel.

handleIpcChannel(addChannelHandler)

After the handler is added, one can start invoking the channel, and send data over to the handler and fully benefit from TypeScript type-hints for the requirements parameters as-well as the return type of the invoke call.

const r = invokeIpcChannel(addChannelHandler, 10, 32)
// Indicates that r is of type Promise<number>
const r2 = invokeIpcChannel(addChannelHandler, "hello", 32)
// Shows an error indicating that "hello" isn't a valid number.

The invokeIpcChannel automatically retrieves the necessary type information from the handler function as defined by the addChannelHandler object.

Footnotes

¹ Throughout this post the invoke -> handler combination will be used. But the same theory obviously goes for the send -> on/once pairs.

References

--

--