| name | js-to-ts-port |
| description | Port a JavaScript codebase to TypeScript using a disciplined two-pass method (pass 1 = best-guess types ignoring errors, pass 2 = drive the typechecker to zero), plus the setup/verification/gotcha checklist learned from doing it. Use when the user asks to "port to TypeScript", "convert this JS project to TS", "add TypeScript", "migrate .js to .ts", "typescriptify", or similar. Covers tooling scaffold, rename-in-place, parallelizing with subagents, and the runtime-breakage traps a typecheck alone won't catch. |
JavaScript → TypeScript port
A repeatable method for converting a JS codebase to TypeScript. The core is a two-pass
type-adding process, wrapped in setup + verification phases. The biggest lesson: a clean
typecheck does NOT mean the app works — type-only changes can still ride alongside
dependency upgrades and "behavior-preserving" refactors that break runtime, so you must run
the app and compare against a baseline.
Confirm the key decisions up front (don't assume): package manager, typechecker (tsc vs
tsgo), bundler/test tools, file layout (rename in place vs move to src/), and strictness.
strictNullChecks should be on from the start regardless.
Phase 0 — Scope & baseline (do this BEFORE touching code)
- Inventory: count/locate the
.js files to port; identify what to exclude (vendored
extern/, oldstuff/, build output, submodules). List the entry point and module-loading
style.
- Map the hard-to-type / dynamic systems so you can design shared types once: class
registries, plugin/
register() side-effects, stringly-typed serialization schemas, data-API
builders, global window.* state, prototype monkey-patches.
- Capture a behavior baseline. Set up the test harness and record reference output of the
current JS app (e.g. Playwright screenshots / a smoke run) before porting. This is your
regression oracle. If a dependency upgrade is part of the task, capture the baseline against
the version you'll actually ship (or note that the "before" is already changing).
Phase 1 — Tooling scaffold
- Package manager / install. If the project pulls a library from source (a submodule),
install its deps too so bare imports resolve.
tsconfig.json: strict: true, moduleResolution: "bundler" (or node-next),
allowImportingTsExtensions: true, noEmit: true, skipLibCheck: true, the right lib
(DOM, ES2022, WebWorker for browser). include your source, exclude vendored/legacy
dirs. Set types: [] for browser code so Node globals don't leak.
- Typecheck: prefer
tsgo (@typescript/native-preview) if requested — tsgo --noEmit.
- Bundler: esbuild bundles
.ts directly and rewrites .js import specifiers to their
.ts siblings, so you can keep all import paths unchanged. ⚠️ esbuild's serve cannot set
custom response headers — if the app needs COOP/COEP/etc., run esbuild serve on an internal
port behind a thin Node proxy that injects them.
- Tests: vitest for units (a placeholder smoke test is fine to start); Playwright for e2e —
run headless Chromium with
--use-angle=swiftshader --enable-unsafe-swiftshader if it renders
WebGL.
- Prove the whole harness works on the un-ported JS first.
Phase 2 — Rename in place
git mv **/*.js **/*.ts for the in-scope files (keeps history; leave vendored/legacy .js).
Keep the .js import specifiers — esbuild and tsgo both resolve ./foo.js → ./foo.ts.
Then confirm the app still bundles and runs as .ts (esbuild strips types; renaming is
behavior-neutral). Fix any import/runtime breakage now, before adding types.
Pass 1 — Best-guess types (IGNORE the typechecker)
Add real, specific type annotations everywhere, working from your design in Phase 0.
- No
any, no unknown, no as-casts. If a type is genuinely undeterminable, leave it
unannotated (let inference handle it) rather than reaching for any/unknown.
- Don't over-annotate. Annotate function parameters, class fields, and non-obvious
returns; omit annotations TypeScript can infer (locals, obvious returns).
- Declare class fields that the original JS only assigned in the constructor.
- Do NOT run the typechecker and do NOT try to make it pass. This pass is about getting
reasonable types down fast; errors are expected and ignored.
- Create the shared foundation types first (registry types, the
XxxDef object returned by
factories, the serialization base classes, a globals.d.ts for window.*), because
everything else imports them.
Pass 1.5 — Coherence review (still no typechecker)
Read the ported files and sanity-check that the annotations cohere and match runtime usage;
strip redundant annotations; fix obviously-wrong guesses. This catches divergent shared-type
definitions before the typechecker amplifies them into cascades.
Pass 2 — Drive the typechecker to ZERO
Now run the typechecker and work the error list down to 0.
- Fix root causes first. A handful of shared-type fixes kill hundreds of cascading errors
(e.g. a registry's element type, an over-narrow tuple
[number,number] that should be
number[], a not-exported helper type). Re-baseline the error count after each big fix.
- Budget:
unknown only with proper narrowing (typeof/instanceof/in/type guards);
treat any as a hard ceiling of ~10 across the whole codebase, each one justified.
Use Reflect.get/set for dynamic property access instead of (x as any)[k]. A single narrow
as Foo at a real boundary (a JSON.parse result, an opaque library return) is acceptable;
as any / as unknown as are not (rare exception: bridging genuine generic invariance, the
same idiom the library itself uses).
- If a type is genuinely unknowable, ask rather than guessing with
any.
- For "possibly undefined/null", add a real guard or
?./??; use ! only when provably safe.
- Keep the bundle building as you go.
Parallelizing large ports (subagents)
For big codebases, fan out with subagents — but coordinate to keep shared types coherent:
- Wave A (foundation): one agent per foundational cluster (core state, the registry/base
classes, the WebGL/IO layer). These define the shared exported types.
- Wave B (leaves): the many files that consume those types (concrete plugins, editors,
UI), after the foundation stabilizes.
- Give every agent the same strict rule block (the Pass-1 or Pass-2 rules above), an explicit
disjoint file list, and the names/locations of the shared types to import. Tell them to
filter the typechecker output to their own files and not weaken exported types just to
silence local errors. Pass 1 can run fully parallel (errors ignored); Pass 2 benefits from
foundation-first ordering. Expect cross-file fallout when a shared signature tightens — mop it
up yourself in a final pass.
Verify (the step people skip)
- Typecheck = 0 errors; report the
any count and each unknown/cast site.
- Production build succeeds AND the dev server runs.
- Run the actual app and compare to the Phase-0 baseline. Switch through every
screen/mode/feature. tsgo will NOT catch: removed library globals, changed library APIs from
an upgrade, shaders/assets that fail at runtime, or a "behavior-preserving" refactor that
wasn't.
- Unit + e2e suites green.
Lessons learned / gotcha checklist
- A green typecheck ≠ a working app. Runtime breakage from dependency upgrades and subtle
refactors is invisible to the typechecker. Always run + compare to baseline.
- Import the library's TS source barrel, not its prebuilt bundle. Pulling both a source
barrel and a
dist/ bundle into one build yields two copies of every module → duplicate
customElements.define ("already used with this registry") and duplicate class identities.
- Implicit globals. Old libs often attached helpers to
window/global; the clean TS
version doesn't. Symbols used-but-never-imported (e.g. a ToolProperty referenced bare)
surface as ReferenceError at runtime, not always as type errors — add the missing imports.
- Stale serialized state. Persisted blobs (localStorage autosave, on-disk saves) from before
a library upgrade can be deserialization-incompatible and crash startup. Clear them when
loading; have tests clear them in setup.
- Module-scoped singletons across bundles. Registries/managers held in module scope (e.g.
nstructjs's struct
manager) are per-bundle — a second bundle (a dev/HMR target) gets its
own isolated copy, which is harmless to the main app but can re-emit "already registered"
warnings. Verify isolation by exercising serialization after the second bundle loads.
- Double-registration bugs. Watch for a thing registered twice (e.g.
inlineRegister() in a
static initializer and a separate register() call). One canonical registration path.
- Union vs intersection for "base + extras" types. A context typed as a union
(
WebGL1 | WebGL2) only exposes common members — WebGL2-only calls error. If the runtime is
really "the superset plus a few custom fields", model it as an intersection
(WebGL2RenderingContext & { customField: ... }).
- Prototype monkey-patches. Don't globally augment a library class's interface to add your
patched fields if the library has its own subclasses with the same field name — it clashes.
Type
this locally in the patch function instead.
- Stochastic / nondeterministic output (random-sampled renders, timestamps): exact-pixel or
exact-string comparison flakes. Use a content-based oracle (e.g. "canvas rendered
non-blank content"; structural assertions) and attach screenshots as artifacts.