Adding TypeSafety to Electron IPC with TypeScript
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.
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.