TypeScript#
MarkText is a TypeScript project. Every file under src/, the build scripts under scripts/, the test specs under test/, the build config (electron.vite.config.ts), and the test configs (vitest.config.ts, playwright.config.ts) are TS.
So is the editor engine: @muyajs/core (packages/muya). The legacy
JavaScript engine it replaced is no longer reachable from here — its
muya/* alias and the src/types/muya.d.ts ambient bridge were removed
once nothing imported them (#4257).
tsconfig layout
Single root project, no composite/references:
tsconfig.base.json # shared compiler options (strict, paths, libs)
tsconfig.json # extends base, adds lib/types/jsx + include/excludeWhy a single project: nothing under src/ needs declaration emission
(the bundler is electron-vite), and the renderer, preload and main
processes share enough types that splitting them into project
references would mostly add ceremony. A single --noEmit project keeps
the wiring minimal without giving up strict mode.
Relevant settings (tsconfig.base.json):
strict: true(every strict flag on)noUncheckedIndexedAccess: false— too disruptive given existing index-access patterns (tabs, listToc)exactOptionalPropertyTypes: false— kept off to keep the buffered-state restore path (which carries optional fields through JSON serialization) tolerant ofundefined≡ "key not present"allowJs: false, checkJs: false— every directory undersrc/is.tsnoEmit: true— vue-tsc only type-checks; electron-vite handles the actual bundling
Path aliases
Defined in both tsconfig.base.json and electron.vite.config.ts (the
two must stay in sync):
| Alias | Maps to |
|---|---|
@/* | src/renderer/src/* |
common/* | src/common/* |
@shared/* | src/shared/* |
vitest.config.ts carries the same aliases plus main_renderer →
src/main for the few unit specs that reach into main-process code.
tsconfig.base.json carries one more entry that is a type-resolution
redirect rather than a module alias: @muyajs/core →
../muya/lib/types/index.d.ts. The package's exports map points . at
its TypeScript source, so without the redirect vue-tsc would pull the whole
muya tree into the desktop's program under the desktop's compiler options.
pnpm typecheck and postinstall both run pnpm --filter @muyajs/core build:types to produce that directory (it is ignored build output).
electron-vite and Vitest are unaffected — they resolve the runtime module
through the exports map.
Where types live
src/shared/types/— cross-process types (IPC contract, file/tab shapes, preferences, menu, bus, TypedEmitter helper). Pure type artefacts, no runtime. Importable from any process via@shared/types/*.src/types/— ambient declarations (global.d.ts,renderer.d.ts,shims.d.ts)..d.tsonly; no runtime.- Co-located — domain types specific to one feature live next to the
code (e.g.
src/main/ipc/ripgrep.tsdefines its ownSearchOptions).
IPC contract
The single source of truth is src/shared/types/ipc.ts. Four channel maps:
IpcInvokeChannels— renderer → main, returns PromiseIpcSendChannels— renderer → main, fire-and-forgetIpcSyncChannels— synchronous renderer → mainIpcMainEventChannels— main → renderer push events
The preload bridge (src/preload/index.ts) consumes these as generics:
const ipcWrapper = {
// … one entry per channel group
send: <K extends keyof IpcSendChannels>(channel: K, ...args: IpcSendChannels[K]) =>
ipcRenderer.send(channel, ...args),
invoke: <K extends keyof IpcInvokeChannels>(
channel: K,
...args: IpcInvokeChannels[K]['args']
): Promise<IpcInvokeChannels[K]['ret']> => ipcRenderer.invoke(channel, ...args)
}Every renderer-side window.electron.ipcRenderer.invoke('mt::fs::stat', p)
call is type-checked: wrong channel name, wrong arg arity, wrong arg
types, all surface at compile time.
To add a new channel:
- Add an entry to the appropriate interface in
src/shared/types/ipc.ts. - Wire the handler in
src/main/ipc/*.tsviaipcMain.handle/ipcMain.on. - Use it from the renderer via
window.electron.ipcRenderer.{invoke,send,…}.
TypedEmitter
Main-process classes that historically extended node:events#EventEmitter
now extend TypedEmitter<EventMap> from @shared/types/typedEmitter.
Event names + listener-argument tuples are typed:
interface BaseWindowEvents {
'window-ready': []
'window-blur': []
'will-close': [id: number, opts: { keepInBackground: boolean }]
}
class BaseWindow extends TypedEmitter<BaseWindowEvents> { ... }Wrong event names or mismatched listener arities fail at compile time.
Pinia stores
All 9 active Pinia stores (src/renderer/src/store/) are typed. Seven are
Setup Stores (defineStore('id', () => { ... return { ...refs, ...computeds, ...actions } })); editor.ts and preferences.ts remain Options Stores
because Pinia's Options-Store typing is fully inferrable from a typed
state: () => State factory, and converting their ~80 cross-store call
sites buys no expressiveness over a typed Options Store.
The editor store's currentFile sentinel is now IFileState | null
instead of the legacy empty-object placeholder, and consumers do explicit
null narrowing where they previously checked !currentFile.id.
Strict-mode landmines
strictPropertyInitialization— class fields must be initialized in the constructor or marked with!:(definite-assignment assertion). Prefer initialization; reach for!:only where the runtime guarantees the field is set before any access.useUnknownInCatchVariables—catch (err)giveserr: unknown. Narrow before using:err instanceof Error ? err.message : String(err).noImplicitAny— every callback parameter needs a type. For iterators (tabs.find(t => ...)), TS infers from the element type.- Event handler unions — Electron's
BrowserWindow.onis heavily overloaded; passing a union of event names ('maximize'|'unmaximize'|...) can require a cast throughany.
Deferred work
All four items from the original deferred-work list landed across PRs #4249–#4255:
- editor.ts + preferences.ts Pinia stores (#4249)
- prefComponents schemas + leaf SFC controls (#4250)
- Test specs ESM + strict TS (#4251)
- Sidebar + top-level page SFCs (#4252)
- Editor + components SFCs (#4253)
- Preference page SFCs (#4254)
@typescript-eslint/no-explicit-anyflipped fromwarntoerror(#4255)
A handful of targeted eslint-disable-next-line directives remain, each
with its own justification — most of them in the CodeMirror 5 mode
plumbing under src/renderer/src/codeMirror/, whose @types/codemirror
surface does not describe the mode API.
The rule covers .vue files as well, and no SFC needs an exemption: the
CodeMirror handles in sourceCode.vue use @types/codemirror and the
editor handle in editor.vue is a shallowRef<Muya | null> typed from
the engine's own declarations (#4257).
Type-checking
pnpm typecheck # vue-tsc --noEmit -p tsconfig.json
pnpm typecheck:watch # incremental
pnpm check # lint + typecheckvue-tsc is the TS compiler with Vue SFC awareness. Plain tsc won't
type-check .vue files. CI runs pnpm typecheck as part of the lint
job (see .github/workflows/lint.yml).
