Notifio is an Electron app. The main process runs a monitor over rental search pages, a local HTTP server, and a reply engine. The renderer is a React app that talks to that server over 127.0.0.1 and an SSE stream. Nothing else.
It has two files called types.ts. src/types.ts has 26 exported types in 350 lines, and src/renderer/types.ts has 16 in 164. Ten names appear in both files.
That is duplication, it is deliberate, and I spent an afternoon last week convincing myself it should stay. This is the argument.
Why they cannot be one file
The renderer bundles through Vite into a browser context. The main process compiles through tsc to CommonJS and runs in Node with electron available. The project has one hard rule about that boundary:
// tsconfig.main.json
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "src/renderer"]
// src/renderer/tsconfig.json
"include": ["./**/*.ts", "./**/*.tsx"],
Two compilers, and neither of them is ever given both files. That is the first thing worth saying out loud: it is not that the type-checker fails to notice the drift between these files, it is that no single invocation of the type-checker has both of them in scope. There is no run in which this could be caught.
A shared types.ts in the middle would fix that, and it would work, right up until somebody adds one import to it. src/types.ts is pure type declarations today, but it sits next to site-state.ts and finds.ts, which import ./paths, which imports electron. One import type { Find } from '../finds' in the renderer and Vite is pulling app.getPath('userData') into a browser bundle. It would fail at build time, loudly, which is fine. It would fail three refactors from now, when somebody is doing something else, which is less fine.
What the renderer actually mirrors
The renderer's file is not a copy of src/types.ts. It is a digest of three separate main-process files, and it says so, four times:
/** Mirrors SiteRuntime in src/site-state.ts. Keep the two in step. */
export type SiteRunState = ...
/** One listing Notifio found. Mirrors Find in src/finds.ts. */
export interface Find { ... }
/** Mirrors MonitorStatus in src/types.ts. Keep the two in step. */
export type MonitorStatus = ...
/**
* A sentence for the user about the run as a whole. Mirrors Notice in
* src/types.ts. A status is one word and cannot explain itself.
*/
export interface Notice { ... }
Four hand-written cross references, each naming a file path in a comment. Comments are the weakest possible enforcement mechanism, and they are also the only one available here, which is at least honest about the situation.
Here is the split, by name:
in both (10) AutoReplySettings, BindingSource, Config, FormFieldSchema,
MonitorStatus, Notice, Recipe, RecipeStep, ReplyMode, Site
main only (16) BlockKind, EmailAlert, FormDrift, FormSnapshot, Listing,
LogFunction, RecipeStatus, ReplyRecord, ReplyStatus,
ScrapeOutcome, ScrapeResult, SelectorCandidate,
StatusListener, StepAction, UserData, ValueStrategy
renderer only (6) Find, LoginState, RecorderState, SiteRunState, SiteRuntime,
SSEMessage
The 16 main-only names are the honest part of the design. ScrapeOutcome, SelectorCandidate, ValueStrategy, ReplyRecord: the renderer has no business knowing any of these, and does not. A single shared file would have put all 16 in front of the UI and invited somebody to use one.
Four of the ten disagree, on purpose
This is where it gets interesting, because "keep the two in step" turns out not to mean "identical".
RecipeStep.action is a union in one file and string in the other.
// src/types.ts
export type StepAction =
| 'click' | 'fill' | 'select' | 'check'
| 'uncheck' | 'press' | 'submit' | 'waitFor';
export interface RecipeStep {
action: StepAction;
...
}
// src/renderer/types.ts
export interface RecipeStep {
id: string;
action: string;
binding?: BindingSource;
field?: FormFieldSchema;
recordedValuePreview?: string;
}
The renderer widens it to string. It never switches on the value, it prints it in a step list, and a narrow union there would mean every new action type is a renderer change too. Widening at a boundary you only read from is not drift, it is the correct variance. Narrowing would be the bug.
Recipe is missing three fields in the renderer. No form: FormSnapshot, no preflight: string[], no successMarkers: string[]. Those three are the replay engine's preconditions, they are sent over the wire, and the renderer simply declares a type that does not mention them. TypeScript is structural, so a response carrying extra fields satisfies the narrower interface without complaint. The renderer is describing the subset of the payload it reads, which is a more useful statement than describing the payload.
FormFieldSchema drops id, autocomplete and maxLength for the same reason.
MonitorStatus has the same seven members in a different order:
// src/types.ts
'not_activated' | 'idle' | 'polling' | 'replying' | 'running' | 'license_invalid' | 'stopped'
// src/renderer/types.ts
"running" | "idle" | "polling" | "replying" | "stopped" | "license_invalid" | "not_activated"
That one is pure accident. Member order in a union has no meaning, so nothing breaks, and nothing will. But it is the clearest evidence of how these files are actually maintained: somebody typed out the set from memory, got the set right and the order wrong, and no process anywhere had an opinion.
You can also tell which file is which from the quotes. The main process is single-quoted, the renderer is double-quoted. Two files, two Prettier-less conventions, one repo.
The real contract is the stream
The thing that made me stop worrying about all this is SSEMessage, which exists only in the renderer:
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 message shapes. The main process has no corresponding type: it builds these objects inline as it emits them. So the renderer's types file is not a duplicate of a contract defined elsewhere, it is the only written-down version of the contract, assembled from the places that happen to produce it.
That reframes the whole thing. These are not two copies of one source of truth that have drifted. They are a producer that describes its internals and a consumer that describes what it has agreed to accept, communicating over JSON, which is exactly the shape you would end up with if the two halves were separate services. They are separate services. One of them is just hosted on 127.0.0.1 inside the same .app bundle.
What I would want, if the drift ever cost us something, is not a shared file. It is one test in the main process that imports SSEMessage-shaped assertions and checks that every emitted message matches, which would need the renderer's file to be importable by tsc, which it is, because it has no imports. That test does not exist yet. The four Mirrors comments are standing in for it, and for seven message types and ten shared names they have held.
If you want to see what the two halves add up to, the app is at notifio.app and the fifteen sites it watches each get their own page.
Top comments (0)