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
- Let the in-flight hexagonal refactor finish its review → verify → fix →
validate cycle.
- Commit it green as a known-good baseline.
- 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
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 linkedits take effect immediately andbin/cuckoocode.jsruns 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.jscan only describe a contract in acomment. Nothing verifies that
adapters/providers/zai.jsimplements it. Wehave
test/architecture.test.jspartly to enforce by assertion what a typesystem would enforce by construction. With TS,
ProviderPortbecomes a realcompile-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. AsPricing | null, rendering a price without a null check simply doesn'tcompile. 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. ARecord<Tier, ModelId>makes an omitted tier a compile error. We have alreadyshipped 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.execveis unaffected.The real cost is a build step for
src/, where today only the Ink UI isbundled.
tsc --watchcovers development;prepublishOnlyalready builds.Lighter alternative, considered and rejected
// @ts-check+checkJsinjsconfig.jsongives type checking with no filerenames 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
@typedefis strictly more awkward than writing them in TS. If we'regoing to be type-checked, do it in the language that has interfaces.
Sequencing — do NOT start this yet
validate cycle.
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 realinterfacesstrict: true, andnoUncheckedIndexedAccess(it directly targets thenullable-catalog case)
dist/;bin/points at compiled outputthe compiler
npm packcontents unchanged apart from the compiled layouttypesfield exported for anyone importing programmaticallyone 45-file commit