docs/dev/TYPESCRIPT.md
MarkText is a TypeScript project. Every file under src/ (except src/muya/),
the build scripts under scripts/, the test specs under test/, the
build config (electron.vite.config.ts), and the test configs
(vitest.config.ts, test/e2e/playwright.config.ts) are TS.
The only JavaScript that ships in the source tree is src/muya/ — the
legacy editor engine, which will be replaced by the upstream TS muya at
https://github.com/marktext/muya. The migration's src/types/muya.d.ts
ambient declaration is the bridge; consumers always go through that file,
never the underlying src/muya/lib/*.js.
Single root project, no composite/references:
tsconfig.base.json # shared compiler options (strict, paths, libs)
tsconfig.json # extends base, adds lib/types/jsx + include/exclude
Why a single project: composite: true requires declaration emission,
which fails on allowJs files that transitively reach into src/muya/
types pulled from @types/trusted-types. A single --noEmit project
sidesteps the issue 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 of undefined ≡ "key not present"allowJs: true, checkJs: false — for src/muya/ only (every other
directory is now .ts)noEmit: true — vue-tsc only type-checks; electron-vite handles the
actual bundlingDefined 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/* |
muya/* | src/muya/* (legacy) |
@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.
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,
muya.d.ts, shims.d.ts). .d.ts only; no runtime.src/main/ipc/ripgrep.ts defines its own SearchOptions).The single source of truth is src/shared/types/ipc.ts. Four channel maps:
IpcInvokeChannels — renderer → main, returns Promise<T>IpcSendChannels — renderer → main, fire-and-forgetIpcSyncChannels — synchronous renderer → mainIpcMainEventChannels — main → renderer push eventsThe preload bridge (src/preload/index.ts) consumes these as generics:
const ipcWrapper = {
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:
src/shared/types/ipc.ts.src/main/ipc/*.ts via ipcMain.handle/ipcMain.on.window.electron.ipcRenderer.{invoke,send,…}.src/muya/ stays JavaScript. The src/types/muya.d.ts ambient
declaration covers the ~21 import paths the rest of the codebase
actually uses (muya/lib/utils, muya/lib/utils/dompurify,
muya/lib/parser/marked/slugger, the dozen-plus muya/lib/ui/* overlay
components, etc.). Most entries are any-typed shims — good enough for
the consumer side, and they delete cleanly the day upstream TS muya
lands.
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.
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.
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) gives err: 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.BrowserWindow.on is heavily
overloaded; passing a union of event names ('maximize'|'unmaximize'|...)
can require a cast through any.All four items from the original deferred-work list landed across PRs #4249–#4255:
@typescript-eslint/no-explicit-any flipped from warn to error (#4255)The only remaining any is the file-level disable in
src/types/muya.d.ts (intentional — bridge to the legacy JS muya tree,
deleted when upstream TS muya lands) and a single targeted
eslint-disable-next-line in src/main/filesystem/watcher.ts for
chokidar's ignored callback options bag whose typed signature varies
between chokidar versions.
A small number of : any annotations remain inside .vue <script setup lang="ts"> blocks — mostly for muya / CodeMirror handles
(MuyaInstance, CMInstance, etc.) kept as file-local aliases on
purpose, parallel to src/types/muya.d.ts. The rule is currently
configured on .ts files only; extending it to the .vue scope is a
separate cleanup once the upstream TS muya replaces those handles with
real types.
pnpm typecheck # vue-tsc --noEmit -p tsconfig.json
pnpm typecheck:watch # incremental
pnpm check # lint + typecheck
vue-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).