Skip to content

Migrate to TypeScript #18

Description

@jellologic

Why it's plain JS today

The original choice was made when this was ~300 lines: no build step on the
launch path, so npm link edits take effect immediately and bin/cuckoocode.js
runs source directly. Defensible for a prototype.

That reasoning has expired. The codebase is now 45 source files, ~5,500 lines,
24 test files, and 6 ports
, and it already carries 75 JSDoc type
annotations
— i.e. we are paying the syntactic cost of types without getting
any checking in return.

The three arguments that actually matter

1. Ports and adapters in a language with no interfaces is the weakest form of
the pattern.
src/ports/provider.js can only describe a contract in a
comment. Nothing verifies that adapters/providers/zai.js implements it. We
have test/architecture.test.js partly to enforce by assertion what a type
system would enforce by construction. With TS, ProviderPort becomes a real
compile-time contract and a non-conforming adapter cannot be written.

2. The nullable-catalog problem is precisely what types are for. ModelScope
publishes ids and nothing else — pricing: null, context: null, tools: null. Our stated requirement is "degrade to honest blanks, never render
$0.00". Today that's enforced by a runtime assertion in picker.test.js. As
Pricing | null, rendering a price without a null check simply doesn't
compile. Given the whole differentiator of this project is refusing to state
things it doesn't know, having the compiler enforce that is on-mission rather
than ceremonial.

3. The four-tier bug class is a type error waiting to happen. #14 shipped
because [1m] is read per variable and one tier silently lacked it. A
Record<Tier, ModelId> makes an omitted tier a compile error. We have already
shipped this bug once to npm.

What TypeScript does not threaten

The launch path must never import React and must stay fast. TS is orthogonal:
it compiles ahead of time, so there is zero runtime cost and the lazy
import('../dist/ui.js') boundary is unchanged. process.execve is unaffected.

The real cost is a build step for src/, where today only the Ink UI is
bundled. tsc --watch covers development; prepublishOnly already builds.

Lighter alternative, considered and rejected

// @ts-check + checkJs in jsconfig.json gives type checking with no file
renames and no new build step. Worth naming because it is genuinely the cheaper
option — but with six ports and an adapter registry, expressing interfaces in
JSDoc @typedef is strictly more awkward than writing them in TS. If we're
going to be type-checked, do it in the language that has interfaces.

Sequencing — do NOT start this yet

  1. Let the in-flight hexagonal refactor finish its review → verify → fix →
    validate cycle.
  2. Commit it green as a known-good baseline.
  3. Then migrate, as its own isolated change.

Converting mid-refactor would conflict across all 45 files, and reviewing an
architecture change and a language change simultaneously means reviewing
neither properly. The 24-file test suite is what makes this migration safe —
do it while those tests are green and stable, so any behavioural drift shows up
immediately.

Acceptance criteria

  • src/ migrated to .ts / .tsx; ports become real interfaces
  • strict: true, and noUncheckedIndexedAccess (it directly targets the
    nullable-catalog case)
  • Build emits to dist/; bin/ points at compiled output
  • Launch path still imports no React — assert it in a test, don't assume it
  • All 24 test files still pass, with no assertion weakened to satisfy
    the compiler
  • npm pack contents unchanged apart from the compiled layout
  • types field exported for anyone importing programmatically
  • Migration done in reviewable slices (ports → core → adapters → UI), not
    one 45-file commit

Metadata

Metadata

Assignees

No one assigned

    Labels

    infraBuild, release, tooling

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions