Zum Hauptinhalt springen

require()import Migration Progress

Tracks the effort to migrate const X = require('...') to typed import statements so values pulled in from other modules carry real types instead of implicit any.

Goal

Replace const X = require('...') with a typed import whenever possible and the change is mechanical. require() is fine to keep when the imported module has no types — it already returns implicit any, so no : any or eslint-disable is needed. The aim is to narrow require to the cases where it's actually load-bearing, and let the type checker cover the rest.

Rules

For each const X = require('...'):

  1. Convert to a typed import when types exist:
    • The module ships its own .d.ts (check node_modules/<pkg>/package.json for a types field).
    • A matching @types/<pkg> is already installed.
    • The module is an internal @joplin/* package or a relative path to a .ts file.
    • It's a Node built-in (path, fs, etc.) — but confirm the installed @types/node covers the specific submodule (e.g. readline/promises needs ≥ 17).
  2. Pick the import form that matches the module's shape:
    • Named exports: import { foo, bar } from 'pkg' (use { foo as bar } to rename rather than a separate const).
    • module.exports = X style: with esModuleInterop: false (this repo's setting), use import * as X from 'pkg'. import X from 'pkg' only works when the typings declare a real default export.
  3. Leave the require() in place when:
    • The module has no types available — migrating would force a // @ts-ignore, a new declare module shim, or a fresh @types/* install.
    • There's an explicit comment explaining why require() is needed (RN bundler compatibility, conditional / lazy / side-effect load, dynamic path).
    • It's part of a : typeof X = require(...) pattern already typed via a paired import type — a deliberate runtime workaround.
    • Converting would require non-mechanical refactoring.
  4. Don't install new @types/* packages or upgrade dependencies as part of this migration. The aim is to surface existing types, not expand the dependency graph.
  5. Don't make whitespace-only changes to surrounding code (per CLAUDE.md).
  6. Don't add explanatory comments unless the why is non-obvious (e.g. "needs import * because the package uses module.exports = ...").
  7. After each package, run yarn tsc --noEmit from the package directory to verify nothing broke. The pre-commit hook handles linting and spellcheck.

Files to never touch

  • packages/generator-joplin/generators/app/templates/** — Yeoman template; package lacks a typescript dep.
  • packages/app-cli/tests/support/plugins/*/api/types.ts and similar — regenerated plugin API copies.
  • Anything under **/node_modules/** or **/build/**.

Workflow

  • One PR per package, smallest first.
  • Update this file as you go, not at the end. Sessions can be auto-compacted or interrupted; the on-disk file is the source of truth.
    • After each file: add an entry to the package's Per-package detail subsection.
    • After each package: update the Status table row.
    • For large packages (lib, app-desktop, app-mobile), checkpoint the row every ~20 files.
  • When resuming, re-read this file first and compare entries against the live state with:```bash grep -rn --include=".ts" --include=".tsx" -E "^\sconst\s+=+=\srequire(" packages/[name here]/

Trust the file, not memory.

## Status

Counts captured 2026-05-25, before any work. `const X = require(...)` occurrences in `*.ts`/`*.tsx` source files, excluding `node_modules/` and `build/`. Some will legitimately stay as `require()` after evaluation (untyped libraries, RN bundler workarounds, etc.) — the "Converted" column tracks how many were actually migrated.

| # | Package | Requires (start) | Converted | Remaining | Status |
| ----- | ----- | ----- | ----- | ----- | ----- |
| 1 | pdf-viewer | 1 | 0 | 1 | done (2026-05-25) |
| 2 | fork-uslug | 1 | 0 | 1 | done (2026-05-25) |
| 3 | default-plugins | 1 | 1 | 0 | done (2026-05-25) |
| 4 | editor | 2 | 0 | 2 | done (2026-05-25) |
| 5 | plugin-repo-cli | 2 | 0 | 2 | done (2026-05-25) |
| 6 | htmlpack | 3 | 3 | 0 | done (2026-05-25) |
| 7 | utils | 9 | 4 | 5 | done (2026-05-25) |
| 8 | renderer | 23 | 7 | 16 | done (2026-05-25) |
| 9 | server | 26 | 9 | 17 | done (2026-05-25) |
| 10 | tools | 49 | 29 | 20 | done (2026-05-25) |
| 11 | app-cli | 49 | 18 | 31 | done (2026-05-25) |
| 12 | app-mobile | 61 | 18 | 43 | done (2026-05-25) |
| 13 | app-desktop | 131 | 74 | 57 | done (2026-05-25) |
| 14 | lib | 195 | 55 | 140 | done (2026-05-25) |
| — | generator-joplin | 1 | — | — | excluded (template) |

Total in-scope `require()` calls at start: **558** (counted across `*.ts`/`*.tsx`, excluding `**/node_modules/**`, `**/build/**` and `**/dist/**`).

## Recommended order

Smallest first so the workflow stabilises before the large ones:

1. Warm-up: pdf-viewer, fork-uslug, default-plugins, editor, plugin-repo-cli, htmlpack (~10 requires total)
2. utils (9)
3. renderer (23)
4. server (27)
5. tools (49)
6. app-cli (54)
7. app-mobile (61)
8. app-desktop (131)
9. lib (195) — biggest; do last so prior packages inform the work

## Per-package detail

Each package gets a subsection added below when work begins. Format:

```markdown
## packages/<name>
Session date: YYYY-MM-DD

Files processed:
- path/to/file.ts — N converted, M left (reasons for skips)

Files skipped entirely:
- path/to/file.ts — reason

packages/pdf-viewer

Session date: 2026-05-25

Files skipped entirely:

  • main.tsx — react-dom/client has types and tsc passes the conversion, but the package's build scripts are all disabled (tsc: "", build: "") and the bundling pipeline that produces the vendor/lib/@joplin/pdf-viewer/ artifact isn't traceable from this repo. The author explicitly chose require() to fix a React 18 warning (commit 58da15432). Leave alone until the bundling path is understood.

packages/fork-uslug

Session date: 2026-05-25

Files skipped entirely:

  • lib/uslug.ts — node-emoji has no types installed.

packages/default-plugins

Session date: 2026-05-25

Files processed:

  • build.ts — 1 converted (yargsimport * as yargs from 'yargs').

packages/editor

Session date: 2026-05-25

Files skipped entirely:

  • CodeMirror/utils/formatting/RegionSpec.ts — imports from @joplin/lib/string-utils-common, which is a deliberate .js file (no .d.ts) per the comment at the top of that file.
  • CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.ts — same as above.

packages/plugin-repo-cli

Session date: 2026-05-25

Files skipped entirely:

  • commands/updateRelease.ts — node-fetch is require(...).default with an explicit comment linking the known node-fetch issue; gh-release-assets has no types installed.

packages/htmlpack

Session date: 2026-05-25

Files processed:

  • packToWriter.ts — 1 converted (html-entities).
  • index.ts — 1 converted (datauri/sync — uses import X = require(...) because the package uses export = X).
  • utils/parseHtmlAsync.ts — 1 converted (@joplin/fork-htmlparser2).

packages/utils

Session date: 2026-05-25

Files processed:

  • Logger.ts — 2 converted (moment, async-mutex), 1 left (sprintf-js — no @types installed).
  • html.ts — 2 converted (html-entities, @joplin/fork-htmlparser2).

packages/renderer

Session date: 2026-05-25

Files processed:

  • htmlUtils.ts — 2 converted (html-entities, @joplin/fork-htmlparser2).
  • utils.ts — 1 converted (html-entities).
  • highlight.ts — 1 converted (highlight.js/lib/core).
  • MdToHtml.ts — 1 converted (html-entities); 4 left: md5 (no types), @joplin/fork-uslug (types describe { default: fn } but index.js unwraps .default, so types are misaligned with runtime), markdown-it-anchor (no types), ./defaultNoteStyle (JS, no .d.ts).
  • MdToHtml/renderMedia.ts — 1 converted (html-entities).
  • MdToHtml/linkReplacement.ts — 1 converted (html-entities); 2 left: ../urlUtils.js (JS, no .d.ts), font-awesome-filetypes (no types).

Files skipped entirely:

  • MarkupToHtml.ts — markdown-it is paired with import type * as MarkdownItType (deliberate workaround for mobile bundling, per the convention this plan skips).
  • HtmlToHtml.ts — md5 (no types).
  • MdToHtml/rules/katex.ts — md5, json-stringify-safe (no types); ./katex_mhchem.js (JS, no .d.ts).
  • MdToHtml/rules/highlight_keywords.ts — md5 (no types); ../../stringUtils.js (JS).
  • MdToHtml/rules/sanitize_html.ts — md5 (no types).
  • MdToHtml/rules/link_open.ts — ../../urlUtils.js (JS).
  • MdToHtml/rules/fountain.ts — ../../vendor/fountain.min.js (vendor JS).

packages/server

Session date: 2026-05-25

Files processed:

  • app.ts — 1 converted (sqlite3); 2 left: @koa/cors (no types), @joplin/lib/shim-init-node.js (lib's .ts source uses module.exports = { ... } rather than ES export, so a typed import {} fails — would require modifying lib to convert).
  • utils/htmlUtils.ts — 1 converted (html-entities).
  • utils/bytes.ts — 1 converted (pretty-bytes, via import X = require() because the package uses export =).
  • utils/joplinUtils.ts — 1 converted (@joplin/lib/string-utils); 1 left (@joplin/lib/database-driver-node.js — JS only, no .d.ts).
  • utils/testing/populateDatabase.ts — 1 converted (sqlite3); 1 left (shim-init-node, same reason as app.ts).
  • utils/routeUtils.ts — 1 converted (@joplin/lib/path-utils).
  • utils/testing/testRouters.ts — 2 converted (query-string, child_process.spawn); 1 left (inline require('child_process').exec inside a function — would require refactoring to top-level).
  • routes/admin/emails.ts — 1 converted (@joplin/lib/string-utils).

Files skipped entirely:

  • config.ts — require(${__dirname}/packageInfo.js) uses a template-literal path; the line is also already typed via : PackageJson.
  • utils/crypto.ts, models/UserModel.ts, routes/index/mfa.ts — thirty-two (no types).
  • utils/prettycron.ts — dayjs and its plugins (advancedFormat, calendar) are convertible, but this is a vendored file (see its header comment) so kept untouched; converting also surfaces a pre-existing noImplicitReturns issue at line 153. later has no types.
  • utils/stripe.ts — stripe has types, but the existing call site uses stripeLib(secretKey) as a function; the typed Stripe is a class requiring new Stripe(secretKey, config). Converting would force a logic/signature change.
  • services/TaskService.ts — node-cron (no types).
  • routes/admin/tasks.ts — prettycron (no types).
  • tools/generateTypes.ts — inline require('fs') inside a function (would require refactoring to top-level).

packages/tools

Session date: 2026-05-25

Files processed:

  • release-clipper.ts — 1 converted (md5-file).
  • generate-database-types.ts — 2 converted (@rmp135/sql-ts merged with existing named import; fs-extra).
  • generate-images.ts — 2 converted (md5-file, sharp). The typed sharp import surfaced a latent issue at line 682 where s = s.toFile(destPath) assigned a Promise<OutputInfo> to a Sharp variable, which fixed in the same follow-up: replaced with await s.toFile(destPath).
  • build-release-stats.ts — 1 converted (yargs-parser).
  • update-readme-contributors.ts — 1 converted (./tool-utils.js merged into the existing import { rootDir }); 1 left (request, no types).
  • release-android.ts — 3 converted (path, uri-template, node-fetch). The typed node-fetch import surfaced a latent issue at line 230 where 'Content-Length': binaryBody.length (a number) didn't match HeadersInit's string requirement; fixed in the same follow-up by wrapping in String(...).
  • website/utils/news.ts — 1 converted (moment).
  • update-readme-sponsors.ts — 1 converted (@joplin/lib/string-utils).
  • website/build.ts — 3 converted (glob, path, md5-file).
  • tool-utils.ts — 3 converted at top level (node-fetch, execa, moment); 7 inline require() calls left inside functions (child_process.exec/.spawn, path, https, crypto, fs, readline) — converting needs moving to module top, which is a small refactor.
  • fuzzer/model/FolderRecord.test.ts — 1 converted (sqlite3); 1 left (@joplin/lib/shim-init-node, same module.exports = { ... } issue as in server).
  • website/processDocs.ts — 4 converted (md5-file, @joplin/fork-htmlparser2, style-to-js — drop .default since export = X is callable, crypto).
  • website/utils/applyTranslations.ts — 2 converted (html-entities, @joplin/fork-htmlparser2).
  • website/utils/frontMatter.ts, website/utils/frontMatter.test.ts — 1 each (moment).
  • website/utils/openGraph.ts — 1 converted (@joplin/lib/string-utils).
  • website/buildTranslations.ts — 1 converted (gettext-extractor).

Files skipped entirely:

  • 6 inline const argv = require('yargs').argv; calls inside main-style async functions (postPreReleasesToForum.ts, tagServerLatest.ts, build-translation.ts, git-changelog.ts, release-android.ts, website/updateNews.ts) — would require a top-level import and a refactor to keep semantics; skipped per the inline-require policy.
  • fuzzer/cli.ts — @joplin/lib/shim-init-node (same as above).
  • convertThemesToCss.ts — require(${baseThemeDir}/${themeFile}) is a dynamic template-literal path.
  • checkLibPaths.test.ts — inline require('../lib/shim') inside a function.
  • utils/translation.ts — gettext-parser (no types).
  • website/updateNews.ts — rss (no types).

packages/app-cli

Session date: 2026-05-25

Files processed:

  • tests/MdToHtml.ts — 2 converted (@joplin/lib/path-utils, @joplin/lib/theme).
  • tests/HtmlToMd.ts — 2 converted (os, @joplin/lib/path-utils).
  • app/command-version.ts — 2 converted (@joplin/lib/locale, @joplin/lib/versionInfo — dropped the .default since import X from resolves the default export).
  • app/command-settingschema.ts, command-testing.ts, command-mkbook.ts, command-e2ee.ts — 1 each converted (./base-command — TS file with export default class BaseCommand).
  • app/command-apidoc.ts — 1 converted (@joplin/lib/string-utils.js@joplin/lib/string-utils).
  • app/cli-integration-tests.test.ts — 3 converted (sqlite3, @joplin/lib/services/SettingUtils, ./utils/shimInitCli); 2 left: @joplin/lib/database-driver-node.js (JS-only), @joplin/lib/shim-init-node.js (lib uses module.exports = {}). The typed shimInitCli surfaced a latent interface mismatch (ShimInitOptions declares sharp, React, electronBridge, pdfJs as required, runtime defaults them to null); fixed in a follow-up by passing them as null explicitly.
  • app/gui/StatusBarWidget.ts — 2 converted (chalk, strip-ansi); 3 left (tkwidgets/BaseWidget.js, tkwidgets/framework/termutils.js — no types, ../autocompletion.js — JS).
  • app/LinkSelector.ts — 1 converted (open).

Files skipped entirely (sibling command files use the CommonJS module.exports = Command pattern — converting requires changing the exported file too, which is out of scope):

  • app/command-unpublish.test.ts, command-publish.test.ts, command-done.test.ts, command-rmbook.test.ts, command-rmnote.test.ts, command-share.test.ts, command-mkbook.test.ts — require('./command-X') patterns.

Files skipped entirely (other reasons):

  • app/command-sync.ts — 1 converted (./base-command); the typed BaseCommand surfaced a latent issue at line 65 (a variadic log: (...s: string[]) callback spread-passed to single-arg stdout); fixed in the follow-up by narrowing the callback to single-arg. Other requires in the file (@joplin/lib/onedrive-api-node-utils.js JS-only, ./cli-utils.js JS-only, md5 no types) left.
  • app/command-keymap.ts, command-ls.ts, command-help.ts, command-import.ts, app/app.ts — ./cli-utils.js and ./help-utils.js are JS-only.
  • app/app.ts — @joplin/lib/Cache is JS-only; line 164 require(\./${path}`)and line 442require('./app-gui.js')` are dynamic/JS.
  • app/services/plugins/PluginRunner.ts, tests/services/plugins/sandboxProxy.ts — @joplin/lib/services/plugins/sandboxProxy is JS-only.
  • app/utils/shimInitCli.ts — @joplin/lib/shim-init-node.js (same lib issue as elsewhere).
  • app/gui/FolderListWidget.ts — tkwidgets/ListWidget.js (no types).
  • app/command-edit.ts, command-e2ee.ts, command-ls.ts — inline require() calls inside functions, or untyped (sprintf-js, md5, fs-extra inline).
  • tests/services/plugins/PluginService.ts — dynamic require(contentScript.path).default.

packages/app-mobile

Session date: 2026-05-25

Files processed:

  • PluginAssetsLoader.ts — 1 converted (@joplin/lib/path-utils).
  • commands/util/showResource.ts — 1 converted (react-native-file-viewer, default import).
  • components/NoteList.tsx — 1 converted (@joplin/lib/locale).
  • components/NoteBodyViewer/hooks/useOnResourceLongPress.ts — 1 converted (@joplin/lib/locale, dropped .js extension).
  • components/SelectDateTimeDialog.tsx — 1 converted (react-native-modal-datetime-picker, default).
  • components/side-menu-content.tsx — 1 converted (@joplin/lib/string-utils).
  • components/screens/JoplinCloudLoginScreen.tsx — 2 converted (react-redux, @joplin/lib/locale).
  • components/screens/Note/Note.tsx — 1 converted (@react-native-clipboard/clipboard, default).
  • components/screens/UpgradeSyncTargetScreen.tsx — 1 converted (react-redux).
  • root.tsx — 1 converted (react-redux); several require()s left for internal mobile .js modules (./components/app-nav.js, ./components/screens/onedrive-login.js) and the @joplin/lib/SyncTarget*.js family — TS counterparts either don't exist or coexist with .js build artifacts in ways Metro currently relies on.
  • services/AlarmServiceDriver.ios.ts — 1 converted (@react-native-community/push-notification-ios, default, merged with existing import type).
  • utils/TlsUtils.ts, utils/setupNotifications.ts — 1 each converted (react-native named imports).
  • utils/buildStartupTasks.ts — 1 converted (react-native-version-info, default); other requires left (SyncTarget JS-only, AlarmServiceDriver platform extensions kept as require to avoid platform-resolution complications during this mechanical pass).
  • utils/fs-driver/fs-driver-rn.ts — 1 converted (rn-fetch-blob, default); md5 left (no types).

Files skipped entirely (latent issues surfaced; reverted for now, worth a follow-up):

  • services/e2ee/RSA.react-native.ts — typed react-native-rsa-native returns KeyPair, but the file casts it to a local LegacyRsaKeyPair interface with a missing keySizeBits property. Real interface drift; needs deciding whether the local interface should adapt or the cast is now wrong.
  • utils/database-driver-react-native.web.ts — @sqlite.org/sqlite-wasm typings only export init (default); the runtime exposes sqlite3Worker1Promiser as a named property. Typed import fails — the .d.ts is incomplete vs the JS. Could be fixed with a local declare module augmentation, but that's beyond a mechanical pass.
  • components/screens/dropbox-login.tsx — typed connect from react-redux initially rejected DropboxLoginScreenComponent because BaseScreenComponent was still require()'d. Fixed in a follow-up: typed both imports, declared Props / State interfaces, and made styles_ / shared_ proper instance fields.

Files skipped entirely (other reasons):

  • utils/initReact.ts — require('react') is deliberate (comment explains the timing constraint with shim.setReact).
  • utils/shim-init-react/index.web.ts — : typeof ShimType = require() is the deliberate paired-with-import type workaround pattern.
  • utils/shim-init-react/index.ts — inline require('react-native-version-info') and JS-only geolocation-react.js.
  • root.tsx — react-native-dropdownalert (no types); see "Files processed" note for the internal mobile .js requires.
  • components/global-style.ts — color (no types).
  • components/ProfileSwitcher/ProfileEditor.tsx — react-native-paper is required as const { TextInput } = require(...) but the package has no types.
  • components/screens/Notes/Notes.tsx, components/screens/Note/Note.tsx (line 85) — @joplin/lib/reserved-ids is JS-only.
  • contentScripts/markdownEditorBundle/useWebViewSetup.ts — @joplin/lib/resourceUtils is JS-only.
  • contentScripts/richTextEditorBundle/contentScript/convertHtmlToMarkdown.ts — @joplin/turndown, @joplin/turndown-plugin-gfm (no types).
  • services/voiceTyping/VoiceTyping.ts, fs-driver/fs-driver-rn.web.worker.ts, services/voiceTyping/whisper.test.ts — md5 / @joplin/whisper-voice-typing no types.
  • services/AlarmServiceDriver.android.ts — @joplin/react-native-alarm-notification (no types).
  • utils/database-driver-react-native.ts — react-native-sqlite-storage (no types).
  • utils/getPackageInfo.ts — ../packageInfo.js (JS-only).
  • components/plugins/backgroundPage/pluginRunnerBackgroundPage.ts — path and punycode (mobile-specific bundling; punycode/ has no types).
  • gulpfile.ts, web/webpack.config.ts — build-time files (gulp, @joplin/tools/*, @pmmmwh/react-refresh-webpack-plugin, ../babel.config are untyped or build-only).

Files skipped entirely:

  • time.ts — 2 deliberate const X: typeof X = require(...) workarounds for React Native bundler compatibility (already typed via paired import type).
  • ipc.ts — tcp-port-used has no types.
  • cli.ts — readline/promises is not in the installed @types/node (16.x); migrating would require a node-types upgrade.

packages/app-desktop

Session date: 2026-05-25

This package has the highest concentration of cascading type errors when imports are typed: typed themeStyle / buildStyle from @joplin/lib/theme propagates strict CSSProperties union types (WhiteSpace, TextAlign, OverflowX, etc.) into inline style objects in many screens; typed connect from react-redux rejects components whose base class is still untyped (similar to the BaseScreenComponent case in app-mobile); typed styled-components and reselect.createSelector produce broad downstream errors. To keep this commit mechanical, those four patterns were intentionally not converted here. Many other safe patterns were converted.

Files processed:

  • ElectronAppWrapper.ts — 1 converted (@joplin/lib/path-utils); 3 left (@joplin/lib/shim typeof-workaround, fs-extra redundant with existing import, inline electron-window-state).
  • InteropServiceHelper.ts — 1 converted (@joplin/lib/path-utils).
  • commands/copyDevCommand.ts — 2 converted (@electron/remote, electron.clipboard).
  • gui/ClipperConfigScreen.tsx — 1 converted (electron.clipboard); connect, themeStyle left (see note above).
  • gui/ErrorBoundary.tsx — 1 converted (electron.ipcRenderer).
  • gui/MainScreen.tsx — 1 converted (electron.ipcRenderer).
  • gui/MenuBar.tsx — 1 converted (electron.clipboard).
  • gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx — 1 converted (electron.clipboard).
  • gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx — 1 converted (electron.clipboard).
  • gui/NoteEditor/NoteEditor.tsx — 1 converted (@joplin/lib/string-utils); themeStyle left.
  • gui/NoteEditor/commands/pasteAsMarkdown.ts — 1 converted (electron.clipboard).
  • gui/NoteEditor/utils/clipboardUtils.ts — 1 converted (electron.clipboard).
  • gui/NoteEditor/utils/contextMenu.ts — 4 converted (electron.clipboard, @joplin/lib/path-utils, two fs-extra).
  • gui/NoteEditor/utils/index.ts — 1 converted (@joplin/renderer MarkupToHtml).
  • gui/NoteEditor/utils/resourceHandling.ts — 1 converted (electron.clipboard); @joplin/renderer.utils left (the .utils namespace access doesn't cleanly map to a named import).
  • gui/NoteEditor/utils/useNoteSearchBar.ts — 1 converted (@joplin/lib/services/CommandService, default).
  • gui/NoteListItem/utils/getNoteTitleHtml.ts — 1 converted (@joplin/lib/string-utils).
  • gui/NotePropertiesDialog.tsx — 1 converted (electron.clipboard).
  • gui/NoteRevisionViewer.tsx — 1 converted (@joplin/lib/urlUtils); react-tooltip reverted because the typed import isn't usable as a JSX component (typings expose only the module namespace); connect left.
  • gui/ResourceScreen.tsx — 1 converted (pretty-bytes); connect, themeStyle left.
  • gui/Root.tsx — 1 converted (react-dom/client); connect, Provider, styled-components ThemeProvider/StyleSheetManager/createGlobalStyle left.
  • gui/Root_UpgradeSyncTarget.tsx — 1 converted (electron.ipcRenderer).
  • gui/ShareNoteDialog.tsx — 1 converted (electron.clipboard).
  • gui/WindowCommandsAndDialogs/commands/deleteFolder.ts — 1 converted (@joplin/lib/string-utils).
  • gui/utils/NoteListUtils.ts — 1 converted (electron.clipboard).
  • main-html.ts — 2 converted (pdfjs-dist, is-apple-silicon); @joplin/lib/shim-init-node.js (lib's module.exports = {} issue) left.
  • plugins/GotoAnything.tsx — 1 converted (electron.clipboard); connect left.
  • services/plugins/PlatformImplementation.ts — 1 converted (electron.clipboard, nativeImage).
  • services/plugins/PluginRunner.ts — 1 converted (electron.ipcRenderer).

Follow-up session (2026-05-25): 5 more safe Node-built-in / typed-package conversions:

  • ElectronAppWrapper.ts — 2 more converted (path, fs-extra); consolidated with the existing import { resolve } from 'path' so resolve(...) is now path.resolve(...).
  • InteropServiceHelper.ts — 1 more converted (url).
  • gui/NoteEditor/utils/resourceHandling.ts — 1 more converted (path).
  • gui/NoteEditor/utils/contextMenu.ts — 1 more converted (fs-extra); folded the existing import { writeFile } from 'fs-extra' into the namespace import.

Follow-up session (2026-05-25): full react-redux connect cluster — all 15 connect requires + 2 require('react') requires converted to typed imports. Zero react-redux/react require()s remain in app-desktop.

Files where the conversion was purely mechanical (Props already declared, or class was <any, any> with a matching (props: any) constructor):

  • gui/HelpButton.tsx, gui/NoteStatusBar.tsx, gui/StatusScreen/StatusScreen.tsx, gui/ImportScreen.tsx, gui/ResourceScreen.tsx, gui/TagList.tsx, gui/NoteRevisionViewer.tsx, gui/JoplinCloudLoginScreen.tsx, plugins/GotoAnything.tsx, gui/ConfigScreen/ConfigScreen.tsx, gui/Root.tsx (just the connect, Provider part — styled-components left).

Files that needed a small typing fix:

  • gui/DropboxLoginScreen.tsx and gui/OneDriveLoginScreen.tsx — class was <any, any> but the constructor took a narrow Props, so TS inferred Props as the component's prop type. Typed connect then required Props to match the connected/external shape. Fix: typed the class as <Props, State> with concrete State interfaces (Dropbox: { loginUrl, authCode, checkingAuthToken } as written by the dropbox-login-shared.js helper; OneDrive: { authLog: { key, text }[] }). Added dispatch: Dispatch (from redux) to Props (already used by the cancel-button handler). Dropbox's Props.style is typed { width: number; height: number } to match what Navigator.tsx passes to its screens.
  • gui/TagItem.tsx — both require('react') and require('react-redux'); the class extended React.Component with no props. Converted to import * as React from 'react', declared Props { themeId, title, id } (all already accessed on this.props), and typed the class as React.Component<Props>.
  • gui/ClipperConfigScreen.tsx — both require('react') and require('react-redux'); the class extended React.Component with no props and the constructor called super() with no args. Typed React needs super(props). Declared Props { themeId, apiToken, clipperServer, clipperServerAutoStart } (all accessed in render()), typed the class as React.Component<Props>, and fixed the constructor.

Follow-up session (2026-05-25): full @joplin/lib/theme themeStyle / buildStyle cluster — all 9 converted. Seven files were purely mechanical: gui/hooks/useMarkupToHtml.ts, gui/KeymapConfig/styles/index.ts (merged with the existing import { ThemeStyle }), gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx, gui/OneDriveLoginScreen.tsx, gui/TagList.tsx, gui/DropboxLoginScreen.tsx, gui/NoteEditor/NoteEditor.tsx. Two needed inline-style annotations to keep literal types from widening: gui/NoteContentPropertiesDialog.tsx (3 styles annotated React.CSSProperties for textAlign) and gui/ResourceScreen.tsx (3 styles annotated for whiteSpace/overflowX).

Follow-up session (2026-05-25): partial styled-components cluster (7 of 9 + reselect.createSelector).

Mechanical (no propagation):

  • gui/Root.tsx (ThemeProvider, StyleSheetManager, createGlobalStyle).
  • gui/NoteEditor/EditorWindow.tsx (StyleSheetManager).
  • gui/Sidebar/styles/index.ts (styled, merged with the existing import { css }).
  • gui/style/StyledTextInput.tsx (styled).
  • gui/ConfigScreen/ButtonBar.tsx (styled).
  • gui/SearchBar/SearchBar.tsx (styled).
  • gui/services/plugins/UserWebviewDialogButtonBar.tsx (styled; the paired styled-system space kept as require() — no types installed).
  • gui/ExtensionBadge.tsx (reselect.createSelector; split the selector's return into four React.CSSProperties constants so the literal values for boxSizing/flexDirection etc. stay narrow).

Still skipped:

  • gui/style/StyledInput.tsx — typing it surfaces a latent bug in PasswordInput.tsx (its custom ChangeEvent { value: string } shape doesn't match what the <input> actually fires) and a related Function-typed handler chain in SearchInput.tsx. Reverted to require() and flagged in review-later.md for a separate fix.

Follow-up session (2026-05-25): typed styled-components in Button.tsx and adjacent fixes. With the underlying typing fixes below in place, every styled-components require() in app-desktop is now converted (9 / 9).

What changed in Button.tsx:

  • Switched ReactButtonProps from HTMLAttributes<HTMLButtonElement> to ButtonHTMLAttributes<HTMLButtonElement>, which restores the type HTML attribute (<Button type='submit' /> in SsoLoginScreen).
  • Declared a local SpaceProps interface (m/mt/mr/mb/ml/mx/my/p/pt/pr/pb/pl/px/py, all optional, number | string) and extended Props with it, so <Button mr=… ml=… mb=… /> calls type-check. styled-system has no @types/* package and the migration explicitly avoids adding new types packages — the local interface documents the runtime-supported props.
  • Collapsed StyledIcon from styled(styled.span(space)) to styled.span<SpaceProps & StyleProps>\${space}; … `so the innermr` prop is in scope on the resulting component (runtime behaviour unchanged).
  • Fixed the pre-existing ${(props: StyleProps) => props.disabled} template interpolation in StyledButtonPrimary / StyledButtonSecondary. The original (introduced in commit 67f0739d3, Nov 2020) interpolated a boolean into the CSS template: false (most buttons most of the time) produced a bare { … } block whose effect depended on the CSS parser's tolerance of malformed input; true produced an invalid true { … } rule that was dropped. Replaced with &:not(:disabled) { to express the obvious intent ("apply hover/active styles only when not disabled"), and removed the now-dead disabled?: boolean field from StyleProps.

Knock-on fixes in callers (made necessary by the typed Button):

  • gui/ConfigScreen/ButtonBar.tsx — narrowed onCancelClick: Function / onSaveClick?: Function / onApplyClick?: Function to ()=> void (dropped three @typescript-eslint/ban-types disables); typed Button's onClick: ()=> void no longer accepts the broad Function.
  • gui/NoteListControls/NoteListControls.tsx — made StyleProps.padding / StyleProps.buttonVerticalGap optional. They were declared required on the shared StyleProps but only used by StyledRoot; with typed styled(Button), <StyledPairButtonL> calls started failing because they (correctly) don't pass either.

styled-system space requires remain in Button.tsx, UserWebviewDialogButtonBar.tsx, and PluginsStates.tsx — the runtime ${space} template still needs the function, and the package has no types.

Files skipped entirely / important categories left untouched:

  • gui/style/StyledInput.tsx — typing it surfaces a pre-existing ChangeEvent { value: string } shape mismatch in PasswordInput.tsx and a Function-typed handler chain in SearchInput.tsx. Flagged in review-later.md for a separate fix.
  • @joplin/lib/services/PluginManager (3), @joplin/lib/onedrive-api-node-utils.js (1), @joplin/lib/markJsUtils (1), @joplin/lib/countable/Countable (1), @joplin/lib/envFromArgs (1), @joplin/lib/components/shared/dropbox-login-shared (1), @joplin/lib/reserved-ids (2), ./packageInfo.js (5), ./services/electron-context-menu (1), ./execCommand (1), ./supportedLocales (1) — JS-only sources.
  • @joplin/lib/shim-init-node.js (2) — same module.exports = { ... } issue described under packages/server.
  • md5 (4), debounce (5), color (2), styled-system (3), taboverride (1), source-map-support (1), react-toggle-button (1), formatcoords (1), gulp (1), @joplin/tools/* (3) — no types installed.
  • Inline require() calls inside functions / arrow callbacks (bridge.ts, mockClipboard.ts, markdownEditor.spec.ts, ElectronAppWrapper.ts electron-window-state) — would require moving to top level.

packages/lib

Session date: 2026-05-25

Many conversions, but several modules surfaced latent type issues that need their own follow-up:

Categories converted (across roughly 40 files):

  • Internal lib paths: ./path-utils, ./string-utils, ./urlUtils, ./markdownUtils, ./errorUtils, ./locale (when paired with a TS source).
  • Node built-ins: path, os, url, events (where it doesn't cascade), https, http, crypto, buffer, zlib, timers, dgram, querystring — namespace or destructured.
  • npm packages: async-mutex (incl. the .Mutex access pattern), html-entities (.AllHtmlEntities), chokidar, fast-deep-equal, query-string, markdown-it, md5-file, string-to-stream, @joplin/renderer (MarkupToHtml), sqlite3.

Latent issues surfaced & reverted (worth follow-ups):

  • moment in time.ts: typed moment() rejects the string | number | Date | { toDate(): Date } union — anythingToDateTime / anythingToMs rely on narrowing o past { toDate(): Date } via 'toDate' in o, but TS doesn't propagate the narrowing into the moment(o, format) call. Fixed in a follow-up by converting to a typed namespace import and adding a const input = o as string | number | Date cast for the moment calls. services/interop/InteropService_Importer_Md_frontmatter.test.ts keeps require('moment') for now.
  • fs-extra in fs-driver-node.ts: typed fs.appendFile(path, content, { encoding }) and fs.writeFile(path, content, { encoding }) don't accept { encoding: string } — they expect { encoding: BufferEncoding }. Fixed in a follow-up by converting the require() to a typed namespace import and casting encoding as BufferEncoding at the three internal fs call sites; the public method signatures stay as string to match the base class.
  • reselect.createSelectorCreator / defaultMemoize and re-reselect.createCachedSelector (in reducer.ts, services/commands/MenuUtils.ts, services/commands/ToolbarButtonUtils.ts): typed createCachedSelector returns a different signature than the codebase assumes — selectArrayShallow(props, cacheKey) then errors with Expected 1 arguments, but got 2. Probably the reselect version was upgraded without updating call sites.
  • events.EventEmitter in services/plugins/Plugin.ts: typed EventEmitter.on(event, fn) rejects Function-typed callbacks. Fixed in a follow-up: tighten the two callbacks to (...args: unknown[]) => void, switch to a typed import { EventEmitter } from 'events', drop the two @typescript-eslint/ban-types disables and the InstanceType<typeof EventEmitter> workaround.
  • @joplin/lib/string-utils formatCssSize in services/style/themeToCss.ts: same latent issue as the desktop's useThemeCss.tsformatCssSize(value: string) was called with number. Fixed in a follow-up by widening the signature to string | number (the body already handled both at runtime); both call sites then convert cleanly to typed imports.

Files skipped entirely (other reasons):

  • @joplin/lib/shim-init-node (multiple places) and JS-only sibling files (./reserved-ids, ./database-driver-node.js, ./file-api.js, ./file-api-driver-onedrive.js, ./file-api-driver-webdav, ./randomClipperPort, ./import-enex-md-gen.js, ./import-enex-html-gen.js, ./resourceUtils.js, ./SyncTarget*.js, ./parameters.js, ./vendor/*, ./welcomeAssets) — JS-only, no TS counterpart.
  • No-types npm packages: sprintf-js (14), md5 (9), url-parse (3), tcp-port-used (2), string-padding (2), @joplin/fork-sax (2), word-wrap, uglifycss, server-destroy, relative, node-notifier, multiparty, image-data-uri, hpagent, diff-match-patch, base64-stream, base-64, @joplin/turndown, @joplin/turndown-plugin-gfm, @aws-sdk/client-s3, @adobe/css-tools, better-sqlite3, sharp, color, @joplin/fork-uslug.
  • Inline require() inside functions / .default accesses on JS-only modules (shim-init-node.ts has many of these inside shimInit).
  • electron requires inside shim-init-node.ts (lib used in node + electron; the require('electron').nativeImage calls inside conditional branches are deliberately late-loaded).
  • EventDispatcher.test.ts line 125: kept as require(...).default because the test specifically asserts that the require + .default pattern works.