A utility type to prepend a function's arguments:
type PrependArgs<ArgsToPrepend extends unknown[], T> = T extends (
...args: infer Args
) => infer Returns
? (...args: [...ArgsToPrepend, ...Args]) => Returns
: never;
Simply provide an array of arguments to prepend, and the type of the function to extend:
type UpdateUsername = (username: string) => void;
type WithEvent = PrependArgs<[Event<HTMLInputElement>], UpdateUsername>;
// (event: Event<HTMLInputElement>, username: string) => void;
Additionally, we can define a convenience utility to prepend all function arguments on properties of a type:
type PrependPropertyArgs<ArgsToExtend extends unknown[], T> = {
[K in keyof T]: PrependArgs<ArgsToExtend, T[K]>;
};
This allows us to prepend all arguments on each of type's properties:
type UpdateHandlers = {
username: (username: string) => void;
email: (email: string) => void;
password: (password: string) => void;
};
type UpdateEventHandlers = PrependPropertyArgs<[Event<HTMLInputElement>], UpdateHandlers>
Example: Handling Electron IPC Events
This example uses the WithPrefix utility mentioned previously. In case you missed it, it's a utility for prefixing a type's properties with some value:
export type WithPrefix<Prefix extends string, T, Separator extends string = '/'> = {
[K in keyof T as `${Prefix}${Separator}${string & K}`]: T[K];
};
Electron apps utilise inter-process communication (IPC) to pass information between main processes and render processes. Typically, an API is defined on the global window that can be used to handle incoming events and initiate outgoing events:
declare global {
interface Window {
electron: ElectronAPI;
api: {
auth: {
login: (payload: LoginPayload) => Promise<LoginResponse>;
logout: () => Promise<boolean>;
isLoggedIn: () => Promise<boolean>;
},
preferences: {
get: () => Promise<Preferences>;
set: (payload: Partial<Preferences>) => Promise<Preferences>;
},
};
}
}
Each of the API methods will initiate some kind of event that is sent to the main process:
export const auth: Window['api']['auth'] = {
async login(payload) {
return await window.electron.invoke('auth/login', payload);
},
async logout() {
return await window.electron.invoke('auth/logout', payload);
},
async isLoggedIn() {
return await window.electron.invoke('auth/isLoggedIn');
},
};
On the main process side we would define corresponding handlers:
ipcMain.handle('auth/login', async (event: Electron.IpcMainInvokeEvent, payload: LoginPayload) => {
return await loginUser(payload);
});
ipcMain.handle('auth/logout', async (event: Electron.IpcMainInvokeEvent) => {
return await logout();
});
ipcMain.handle('auth/isLoggedIn', async (event: Electron.IpcMainInvokeEvent) => {
return await isLoggedIn();
});
Typically, we wouldn't want to call our handlers like this. Instead, we would list them somehow and iterate over them to register each handler:
const authHandlers = {
'auth/login': handleAuthLogin,
'auth/logout': handleAuthLogout,
'auth/isLoggedIn': handleAuthIsLoggedIn,
};
for (const [channel, handler] of Object.entries(authHandlers)) {
ipcMain.handle(channel, handler);
}
This is a nice way to DRY things up. But there are some issues with this approach:
- We have no way of knowing whether our implementation covers all of the API methods.
- We have to define types for each handler with the only difference being that we prepend an
Electron.IpcMainInvokeEvent
argument.
We can solve both of these issues with a combination of WithPrefix
and PrependPropertyArgs
:
type AuthIpcEvents = PrependPropertyArgs<
[Electron.IpcMainInvokeEvent],
WithPrefix<'auth', Window['api']['auth']>
>;
-
WithPrefix<'auth', Window['api']['auth']>
prefixes our types -
PrependPropertyArgs<[Electron.IpcMainInvokeEvent], T>
adds the event parameter as the first argument to each method.
This results in the following type:
type AuthIpcHandlers = {
'auth/login': (event: Electron.IpcMainInvokeEvent, payload: LoginPayload) => Promise<LoginResponse>;
'auth/logout': (event: Electron.IpcMainInvokeEvent) => Promise<boolean>,
'auth/isLoggedIn': (event: Electron.IpcMainInvokeEvent) => Promise<boolean>,
};
Now, if we added an api.auth.register
method, we would get a type error:
// TS2741: Property 'auth/register' is missing in type AuthIpcHandlers
const authHandlers = {
'auth/login': handleAuthLogin,
'auth/logout': handleAuthLogout,
'auth/isLoggedIn': handleAuthIsLoggedIn,
};
And our handlers will warn us when we don't provide the event and required arguments:
// TS2554: Expected 2 arguments, but got 1.
const handleLogin: AuthIpcHandlers['auth/login'] = async (payload) => {};
Breaking it down.
This one is relatively simple. We are just defining a new function with our prepended arguments:
type PrependArgs<ArgsToPrepend extends unknown[], T> = T extends (
...args: infer Args
) => infer Returns
? (...args: [...ArgsToPrepend, ...Args]) => Returns
: never;
- We define generic type
ArgsToPrepend extends unknown[]
so that we can accept any type. - We state that T must be a function type
T extends (...args: infer Args) => infer Returns
and also infer the type of itsArgs
andReturns
. - We combine our
ArgsToPrepend
with the inferredArgs
, resulting in a new function type:(...args: [...ArgsToPrepend, ...Args]) => Returns
. - Finally, if we have passed a non-function type, we return
never
.
Hopefully you find this useful :)
Top comments (0)