Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. Its window is a React app, and it does not reach the main process through IPC or a preload bridge. It makes HTTP requests to an Express server running inside the same Electron process, which I have written about before and still think is the right call for this app.
It does have one consequence nobody mentions when they recommend it. The boundary is JSON over a socket, so TypeScript cannot cross it. The renderer gets its data by parsing a response body, and parsing a response body produces any. Somebody has to write down what comes back.
So there are two type files:
app/src/types.ts 350 lines, 26 exported types app/src/renderer/types.ts 164 lines, 16 exported types Ten names appear in both. I had assumed that meant ten chances to be wrong, and that some of them would already be wrong. This post is the diff.
Why not just import the other file
The renderer could import ../types from the main process tree. It is the same repository, a few directories up. Two things stop it.
The first is that they compile under different compilers. The main process is tsc --project tsconfig.main.json to CommonJS in dist-ts/. The renderer is Vite, with its own tsconfig:
{ "compilerOptions": { "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2020", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "isolatedModules": true, "noEmit": true }, "include": ["./**/*.ts", "./**/*.tsx"] } Note the include. The renderer's type check cannot see the main process tree at all, which is deliberate: lib has no node in it, and importing a module whose siblings import fs and electron makes a mess of that boundary quickly.
The second reason is better. The main process types describe what the app holds in memory. The renderer types describe what the HTTP responses contain, which is not the same set of facts, and the second one is allowed to be smaller.
The diff
Normalising whitespace, comments and quote style, then comparing the ten shared names:
| Type | Verdict |
|---|---|
AutoReplySettings | identical |
BindingSource | identical |
Config | identical |
Notice | identical |
ReplyMode | identical |
Site | identical |
MonitorStatus | same seven members, different order |
FormFieldSchema | renderer has 9 of 12 fields |
Recipe | renderer has 12 of 15 fields |
RecipeStep | renderer has 4 of 11 fields, plus one of its own |
Six exact matches and one that differs only in the order its union members are written. That is a better result than I expected from two hand-maintained files, and I think the reason is boring: those six are the shapes that appear on screen, so a drift in any of them shows up as a blank field in the window the next time I run the app.
The three that differ are all from the same corner of the product, and all three differ in the same direction.
Narrower on purpose
Recipe in the main process has 15 fields. The renderer declares 12, and drops three:
// main process export interface Recipe { id: string; domain: string; name: string; form: FormSnapshot; // not in the renderer steps: RecipeStep[]; preflight: string[]; // not in the renderer successMarkers: string[]; // not in the renderer status: RecipeStatus; ... } Those three are internals of replaying a reply. The window shows a row per site with a status and a count on it, so it has no reason to know they exist, and the route that feeds it does not send them.
RecipeStep is the sharpest version of this. Eleven fields in the main process, four in the renderer, and one difference that is not about fields at all:
// main process action: StepAction; // renderer action: string; StepAction is a union. The renderer widens it to string because it only ever renders it as a label, and a union it does not need to exhaust is a union it would have to keep in step for no benefit. Widening on purpose at a boundary is underrated. The narrow type is the one that has to be right where the behaviour is, and this is not that side.
The same pattern appears in Recipe.status, where the renderer inlines the three string literals rather than importing a named union, and in FormFieldSchema, where the renderer skips id, autocomplete and maxLength because it is drawing a summary of a form and not rebuilding it.
So the answer to "how much have the duplicated types drifted" is: not at all, in the sense I was worried about. Every difference is a projection, and every projection is the smaller side knowing less.
The field that exists nowhere else
Then there is this, in the renderer's RecipeStep:
export interface RecipeStep { id: string; action: string; binding?: BindingSource; field?: FormFieldSchema; recordedValuePreview?: string; } $ grep -rn "recordedValuePreview" app server --include="*.ts" --include="*.tsx" app/src/renderer/types.ts:136: recordedValuePreview?: string; One hit, the declaration itself. No route sets it, no component reads it, the main process has never heard of it. It is a field invented for a UI that was going to show you a masked preview of each recorded step, declared before the component, and then the component was built differently.
It is optional, so nothing ever complained. That is the whole failure mode of a hand-written projection: a missing field is caught by the compiler the moment somebody reads it, and an invented field is caught by nothing, because undefined is a legal value of string | undefined and the server not sending it looks exactly like the server sending nothing.
Two more copies, and one that is checked by eye
The ten shared names are not the whole duplication. Three of the renderer's types are copies of shapes that live somewhere other than types.ts:
/** Mirrors SiteRuntime in src/site-state.ts. Keep the two in step. */ export type SiteRunState = "unknown" | "checking" | "ok" | "blocked" | "auth_failure" | "error"; SiteRunState and SiteRuntime are declared in src/site-state.ts, where the monitor's per-search state lives, and Find is declared in src/finds.ts. All three are identical in both places today, 6 members and 13 fields and 6 fields respectively. The comment is the enforcement mechanism, which is to say there is no enforcement mechanism.
Counting those, 13 of the renderer's 16 types are second copies of something. Only three are its own, and they are the three that describe things which do not exist anywhere else:
-
LoginState, a six-value union about whether a search has a usable session, which replaced an underscore-prefixed field that three components used to patch -
RecorderState, which is a screen, not data -
SSEMessage, the wire protocol
SSEMessage is the one I checked most carefully, because it is the only type on the renderer side with no counterpart at all: the main process never declares the frames it pushes, it writes them inline.
export type SSEMessage = | { type: "log"; line: string } | { type: "status"; status: MonitorStatus } | { type: "auth_failure"; url: string } | { type: "blocked"; url: string } | { type: "sites"; sites: SiteRuntime[] } | { type: "finds"; finds: Find[] } | { type: "notice"; notice: Notice | null }; Seven variants. The server has exactly seven broadcast({ type: ... }) call sites, and the seven names match. That one surprised me, because it is the type with the least compiler pressure behind it on either side.
What I would do about it
Not codegen. Generating 13 types across a boundary that produces one phantom optional field every few months is more machinery than the problem deserves, and it would couple the renderer's build to the main process's type check, which is the coupling the two tsconfigs exist to prevent.
What the measurement actually argues for is a test. Both files are plain TypeScript with no imports worth mocking, so a check that reads both sources, pulls out the shared names, and asserts that every field in the renderer's copy exists in the main process copy would be about 30 lines and would have caught recordedValuePreview the day it was written. Subset, not equality: the narrowing is the design, and a test that demanded equality would be a test demanding the renderer learn things it has no business knowing.
If you want to see the UI these types describe, the app is on the download page and the help page walks through what each part of the window is for. I have also written about where state lives in this app, which is the other half of this story: these are the shapes, that one is the lifetimes.