readme/dev/js_to_ts_progress.md
.js → .ts Migration ProgressTracks the effort to convert remaining JavaScript source files in the Joplin repo to TypeScript.
Convert .js files under packages/* to .ts (or .tsx where JSX is present) so the type checker can validate them, without changing observable behaviour and without significant refactoring. The point is mechanical type-coverage, not redesign — every conversion should be reviewable as "the same code, now typed".
For each .js file to convert:
Use git mv to record the rename. Don't cp/rm.
Actively look for the right type before falling back to any. For each parameter / return / field, try in this order:
@joplin/lib/services/database/types (NoteEntity, FolderEntity, TagEntity, ResourceEntity, …)..d.ts in node_modules/<pkg>/.cmd, options, etc. often has a typed counterpart nearby.State from @joplin/lib/reducer and Store<State> from redux.app() returning Application), the class may need a one-line export added in its source file before it can be imported.Record<string, any>.Only fall back to any when the right type genuinely isn't available — typically callbacks from untyped third-party libraries or truly heterogeneous bags. Add // eslint-disable-next-line @typescript-eslint/no-explicit-any -- <reason> only when ESLint actually fires. An implicit any from const X = require('untyped-pkg') is fine and needs no disable — but prefer a typed import (import X from 'pkg' or import * as X from 'pkg') when the package ships its own .d.ts; reach for require() only when the dependency is genuinely untyped or when the surrounding code already uses CommonJS for a reason (e.g. the app-cli command dispatcher).
Treat every @typescript-eslint/no-explicit-any disable as a candidate for one more look before committing — sometimes the right type only becomes obvious after a second pass over the file, or after finishing related files in the same round. If a tightening genuinely doesn't fit in the conversion commit (e.g. it requires exporting a type from another file or coordinating across files), do it as its own small follow-up commit; the any cleanup guide covers the methodology for that kind of work.
Match the export style of nearby converted files.
packages/app-cli/app/command-*.ts use module.exports = Command; (the dispatcher loads them via require() and calls new CommandClass() without .default).packages/app-cli/app/**/*.ts (widgets, helpers) use export default … and consumers add .default to their require() — see gui/FolderListWidget and gui/StatusBarWidget as in-tree precedents.packages/lib/**/*.ts files use export default … for single-class/function modules and named export const … for utilities.export const X = … to export default X just to silence import/prefer-default-export when the file name and symbol name diverge — add a second meaningful export (often a type/interface the callers benefit from), or use // eslint-disable-next-line import/prefer-default-export -- <reason>.Behavior-preserving casts where types are too narrow. Apply a local as cast with a one-line comment and log the underlying signature mismatch somewhere for follow-up (the PR description, an issue, or a per-project review-later note) — fixing the upstream type is a correctness improvement out of scope for a mechanical conversion.
Dead code surfaced by the type checker. Unused private fields/parameters may be removed in the conversion commit when:
Unused parameters of callbacks from untyped third-party libraries (e.g. mark.js's filter(node, term, termCounter, counter)) must be kept with underscore prefixes (_term, _termCounter, _counter) — the signature documents the contract since no .d.ts exists.
Build verification after each conversion, from the repo root:
yarn updateIgnored — auto-appends the compiled .js paths to .gitignore and .eslintignore under the # AUTO-GENERATED marker. Without this, yarn tsc output gets committed by accident.yarn tsc --noEmit to type-check; then yarn tsc to produce the compiled .js.Update consumers when the export shape changes. Named-export conversions usually need no caller updates (destructured require() continues to work). module.exports = X → export default X conversions require callers to add .default. Do these in the same commit.
Don't add explanatory comments unless really needed. A typed parameter or cast usually speaks for itself. Only comment when the why would otherwise mislead a future change. Long block comments describing what the code does are unwelcome.
Pre-commit hook failures. git commit may fail with yarn linter-precommit failed to spawn: SIGKILL — the SIGKILL message hides the real lint/spellcheck error above. Use git commit ... 2>&1 | tail -40 to see it. Common blockers:
// cSpell:ignore word1 word2 at the top of the file or wrap an offending block in // cSpell:disable / // cSpell:enable. For real technical words, extend packages/tools/cspell/dictionary*.txt per readme/dev/spellcheck.md instead..gitignore/.eslintignore out of date: re-run yarn updateIgnored.checkGeneratedFiles: a .js that should be compiled output is still git-tracked. git rm --cached <path>.import/prefer-default-export: see rule 4.@typescript-eslint/no-floating-promises: pre-existing fire-and-forget — add void in front of the call.id-denylist: a denylisted identifier (e.g. notebook) appears as a CLI arg name. Add // eslint-disable-next-line id-denylist -- <reason> on the line.For CLI changes, run the fuzzer. Every 3–5 commits in packages/app-cli/, run yarn syncFuzzer start --steps 5 from the repo root as an end-to-end smoke check. 5 steps is the CI default (under a minute); 50 steps takes several minutes — only use when looking for race conditions. The fuzzer is also the primary verification for sync-target files (SyncTarget*.ts, *Api.ts, file-api-driver-*.ts); yarn jest doesn't meaningfully exercise them.
Test files come after their source. Converting Foo.test.js is high-value only when Foo.ts is already TypeScript. When Foo.js is still JS, convert the source first, then the test, in two separate commits.
Rename legacy aspect-test names. When converting a Foo_Aspect.test.js, also rename to Foo.aspect.test.ts to match the convention used elsewhere (e.g. Note.customSortOrder.test.ts).
No whitespace-only changes to surrounding code (per CLAUDE.md).
.js by design:
.eslintrc.js, jest.config.js, jest.setup.js, *.config.js, gulpfile.js, webpack.config.js, babel.config.js, metro.config.js, *sidebars.js (Docusaurus), lint-staged.config.jspackages/app-cli/app/main.js — has a #!/usr/bin/env node shebang that tsc would strip, breaking the executable. Conversion needs a shebang-preservation strategy first (a tsconfig banner, a thin shebang-only wrapper, or a post-tsc gulp step). After any such attempt, verify the shebang is still on line 1 of the compiled output.packages/lib/markJsUtils.js — converting it broke the desktop note viewer (yarn test-ui markdownEditor in packages/app-desktop/ reported failures; yarn tsc and yarn jest did not). Most likely cause: a downstream bundler ships this file to a browser context where tsc's CommonJS-wrapper output (Object.defineProperty(exports, '__esModule', …), exports.default = …) isn't valid in the executing environment. Don't retry without first identifying the bundling path and confirming yarn test-ui markdownEditor stays green afterwards.packages/generator-joplin/ — the package does not depend on typescript. Converting would require adding the dep and tsconfig wiring. Out of scope; if explicitly requested, surface the missing-dependency issue first.packages/fork-sax/**, packages/fork-uslug/**packages/turndown/src/**, packages/turndown-plugin-gfm/src/**packages/lib/countable/Countable.jspackages/app-clipper/content_scripts/Readability*.js, packages/app-clipper/content_scripts/JSDOMParser.jspackages/renderer/assets/**/*.min.js (abc, mermaid, …)packages/app-cli/tests/support/plugins/**/*.jspackages/app-desktop/integration-tests/resources/test-plugins/**/*.jspackages/app-desktop/locales/index.js and similar locale indexespackages/app-desktop/services/plugins/plugin_index.jspackages/server/public/js/** (server-side static assets shipped to browsers)packages/app-mobile/web/mocks/** (webpack aliases for Node-builtin shims).packages/app-cli/app/fuzzing.js (2400+ lines, exploratory test runner — convert only if specifically requested).packages/app-cli/app/build-doc.js (dev-time documentation generator — low value, skip unless requested).BaseCommand.description() to return string — go in their own follow-up commit. Use the message form Chore: Migrate <area> <Name> to TypeScript.any cleanup file — write per-file entries immediately to ./js_to_ts_progress_details.md, update the Status table row after each package, and checkpoint every ~10 files in big packages so a context-loss costs at most one checkpoint.gh pr edit <PR-number> --body-file readme/dev/js_to_ts_progress.md.Same as the any cleanup guide — quality degrades before context fails, one package per session, on-disk file is the only reliable resume record, re-read when resuming.
Counts captured against the dev branch before any conversion work from this plan landed. Excludes the "Files to never touch" categories above. Numbers are approximate; re-verify at session start with:
git ls-files packages/<name>/ | grep -E '\.js$' | grep -v -E '<the excludes>'
| # | Package | .js source | .test.js | Notes |
|---|---|---|---|---|
| 1 | pdf-viewer | 1 | 0 | warm-up |
| 2 | transcribe | 1 | 0 | warm-up |
| 3 | react-native-saf-x | 1 | 0 | warm-up |
| 4 | react-native-alarm-notification | 1 | 0 | warm-up |
| 5 | turndown | 1 | 0 | check if vendored before converting |
| 6 | turndown-plugin-gfm | 6 | 0 | likely vendored; verify first |
| 7 | onenote-converter | 3 | 0 | wrapper over Rust/WASM module |
| 8 | server | 2 | 0 | public-facing browser JS; verify safe |
| 9 | doc-builder | 4 | 0 | Docusaurus components |
| 10 | app-clipper | 9 | 0 | exclude Readability* / JSDOMParser |
| 11 | app-cli | 9 | 0 | excluding main.js, fuzzing.js, build-doc.js |
| 12 | renderer | 12 | 0 | excluding asset bundles |
| 13 | app-mobile | 12 | 0 | excluding web/mocks/* |
| 14 | tools | 17 | 0 | many are dev scripts; selectively convert |
| 15 | app-desktop | 28 | 0 | mix of services/gui/style |
| 16 | lib | 36 | 12 | biggest; sync targets, models, utilities |
| — | generator-joplin | — | — | excluded (no TS dep) |
Approximate scope: ~143 source .js files + ~12 lib .test.js files. Other packages' tests use different patterns (tests/feature_*.js in app-cli, etc.) — count those when starting the package.
Smallest / lowest-risk packages first to validate the workflow and surface patterns:
pdf-viewer, transcribe, react-native-saf-x, react-native-alarm-notification, turndown (if not vendored).renderer (12) — markdown rule modules; well-typed dependency surface.app-clipper (9) — popup app; verify the webpack bundle still loads after each.app-cli (9) — feature tests and small remaining utilities.server (2) — check whether public/js/ is loaded raw or bundled.doc-builder (4) — Docusaurus-side; dev-time only, low blast radius.onenote-converter (3) — small wrapper.tools (17) — independent dev scripts; convert incrementally.app-mobile (12) — verify the Metro bundler still ships the files cleanly.app-desktop (28) — split into commit-sized chunks.lib (36 source + 12 tests) — biggest; group by sub-area (sync targets, models, utilities) so each PR is focused.Each package gets a subsection added to ./js_to_ts_progress_details.md when work begins. See that file for the structure (mirrors any_cleanup_progress_details.md).