Portable Types
Share IPC type definitions with external projects using dts-bundle-generator.
When building an Electron app that loads external web content (like a webapp from a different repository), you may want to provide type-safe IPC access to that external code. This guide shows how to generate portable type definitions that can be copied to other projects.
The Problem
Your Electron app has IPC interfaces defined in EIPC:
module myapp.web
structure UserPreferences {
theme: string
fontSize: number
}
[RendererAPI]
[ContextBridge]
interface Settings {
getPreferences() -> UserPreferences
setPreferences(prefs: UserPreferences)
}
A webapp loaded in your Electron app (via <webview> or loadURL) needs to call these APIs with full type safety — but it lives in a different repository and can’t import from your Electron app’s source.
The Solution
Use dts-bundle-generator to bundle your IPC type definitions into standalone .d.ts files that can be copied to other projects.
Setup
Install dts-bundle-generator:
npm install -D dts-bundle-generator
Build Script
Create a build script that generates both the normal IPC wiring and portable type bundles:
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'child_process';
import { generateWiring } from '@marshallofsound/ipc';
// Modules to expose to external apps
const PORTABLE_MODULES = ['myapp.web'];
export async function generateIPCWiring(buildPortable: boolean) {
const schemaDir = path.resolve(__dirname, '../src/schema');
const outputDir = path.resolve(__dirname, '../src/ipc');
// Generate normal wiring
await generateWiring({
schemaFolder: schemaDir,
wiringFolder: outputDir,
});
if (!buildPortable) return;
// Clean portable output directory
const portableDir = path.resolve(__dirname, '../portable-api');
await fs.promises.rm(portableDir, { recursive: true, force: true });
await fs.promises.mkdir(`${portableDir}/common`, { recursive: true });
await fs.promises.mkdir(`${portableDir}/renderer`, { recursive: true });
const banner = `/**
* AUTO-GENERATED FILE - DO NOT EDIT
*
* Copy this file from the electron-app repo.
* Run \`npm run build:portable\` to regenerate.
*/`;
for (const moduleId of PORTABLE_MODULES) {
// Bundle type definitions into single .d.ts file
await spawnAsync('npx', [
'dts-bundle-generator',
`src/ipc/common/${moduleId}.ts`,
'-o', `portable-api/common/${moduleId}.d.ts`,
'--no-banner',
'--no-check',
'--project', 'tsconfig.json',
]);
// Add banner to generated types
const typesPath = `${portableDir}/common/${moduleId}.d.ts`;
const types = await fs.promises.readFile(typesPath, 'utf-8');
await fs.promises.writeFile(typesPath, `${banner}\n/* eslint-disable */\n${types}`);
// Copy renderer runtime
const rendererPath = `src/ipc/renderer/${moduleId}.ts`;
const renderer = await fs.promises.readFile(rendererPath, 'utf-8');
await fs.promises.writeFile(
`${portableDir}/renderer/${moduleId}.ts`,
`${banner}\n${renderer}`
);
}
}
function spawnAsync(cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, { stdio: 'inherit' });
proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Exit ${code}`)));
});
}
Package Scripts
Add scripts to your package.json:
{
"scripts": {
"build:ipc": "tsx build/ipc-wiring.ts",
"build:portable": "tsx build/ipc-wiring.ts --portable"
}
}
Output Structure
After running npm run build:portable, you’ll have:
portable-api/
├── common/
│ └── myapp.web.d.ts # Bundled type definitions
└── renderer/
└── myapp.web.ts # Runtime code for calling IPC
Using in External Project
Copy the portable-api directory to your external webapp project:
webapp/
├── src/
│ ├── electron-api/ # Copied from portable-api
│ │ ├── common/
│ │ │ └── myapp.web.d.ts
│ │ └── renderer/
│ │ └── myapp.web.ts
│ └── app.tsx
└── tsconfig.json
Import and use with full type safety:
import { Settings } from './electron-api/renderer/myapp.web';
// Fully typed!
const prefs = await Settings.getPreferences();
console.log(prefs.theme); // TypeScript knows this is a string
await Settings.setPreferences({
theme: 'dark',
fontSize: 14,
});
Handling External Dependencies
If your IPC types reference external packages, use --external-inlines to inline those types:
await spawnAsync('npx', [
'dts-bundle-generator',
`src/ipc/common/${moduleId}.ts`,
'-o', `portable-api/common/${moduleId}.d.ts`,
'--no-banner',
'--no-check',
'--external-inlines', 'some-package', // Inline types from this package
'--project', 'tsconfig.json',
]);
Automation
Set up a CI job to regenerate and commit portable types when schemas change:
# .github/workflows/portable-types.yml
name: Update Portable Types
on:
push:
paths:
- 'src/schema/**/*.eipc'
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run build:portable
- uses: peter-evans/create-pull-request@v5
with:
commit-message: 'chore: update portable IPC types'
title: 'Update portable IPC types'
branch: update-portable-types
Best Practices
-
Version your portable types — Include a version comment or file so consumers know when to update
-
Document the copy process — Add a README in
portable-api/explaining how consumers should use the files -
Only expose what’s needed — Don’t make all modules portable, only those specifically designed for external consumption
-
Consider security — Portable modules are accessible to external code loaded in your app; use Validators to restrict access appropriately