TypeScript without the rewrite

J05N adopted TypeScript at contracts and package boundaries while existing JavaScript kept shipping through a measured, removable migration path.

The first TypeScript proposal I wrote was secretly a rewrite proposal.

It described converting J05N, then converting the applications that consumed it, then enabling strict settings once the errors were manageable. The diagram moved neatly from JavaScript on the left to TypeScript on the right. It did not explain what value arrived before the last file changed.

That was the same destination-first mistake I had made with the original component library.

By August 2017, TypeScript 2.4 offered a much better migration surface than my plan acknowledged. Existing JavaScript could participate through allowJs; TypeScript 2.3 had added checkJs and a strict umbrella for stronger checking; declaration files could express package contracts without requiring every consumer to change language at once. The compiler was not demanding a flag day. I was.

J05N changed the unit of adoption. I would type maintained boundaries first, allow JavaScript and TypeScript to coexist, and use each conversion to remove a known ambiguity. File extensions would record progress, not define success.

The goal was not more typed files

J05N had accumulated several classes of uncertainty:

  • A dialog accepted incompatible dismissal options.
  • A status component confused failed operations with unknown outcomes.
  • Theme mappings omitted new semantic tokens until a visual test happened to cover them.
  • Compatibility wrappers accepted old and new prop names together.
  • Package consumers could pass structurally plausible values whose state combinations made no sense.

These were contract problems. Converting a utility that joined class names would increase the TypeScript percentage and address none of them.

I ranked migration candidates by boundary value:

public package exports
state machines and discriminated outcomes
theme contract
migration adapters
pure helpers with subtle input assumptions
leaf presentation components
fixtures and scripts

Public exports came first because an error there affected several consumers. State models came early because invalid combinations were costly. Stable leaf utilities could wait unless their conversion unlocked a boundary.

The metric was not lines converted. It was ambiguities removed from maintained contracts.

This kept the migration connected to J05N's purpose instead of becoming a language project that happened to occupy the same repository.

JavaScript and TypeScript shared one build

The first useful configuration included existing .js and new .ts or .tsx files together:

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "jsx": "react",
    "module": "commonjs",
    "target": "es5",
    "declaration": true,
    "noImplicitAny": true,
    "strictNullChecks": true
  },
  "include": ["src/**/*"]
}

The exact output configuration remained aligned with the package's existing Babel and bundling path. TypeScript's first role was checking and declaration generation; I did not change module format, JSX behavior, test runner, and consumer build in the same step merely because the compiler could emit JavaScript.

allowJs let typed modules import existing JavaScript while the repository remained mixed. checkJs stayed off globally at first because enabling it across historical code produced a volume of findings with no migration order. Selected JavaScript files opted into checking through focused configuration or file directives once their dependencies were understood.

Strictness applied to new TypeScript from the beginning. Starting loose and promising to become strict later would create another migration whose cost grew with every file.

Coexistence was not a compromise after failed conversion. It was the designed intermediate state.

J05N already had JavaScript components with reasonably stable runtime behavior. Rewriting their implementation before expressing the public contract would delay the most useful feedback.

I wrote declarations for the package exports. A field contract, for example, distinguished required properties, optional description, error state, and callback shape. The dialog surface named initial-focus and dismissal strategies. Theme declarations listed every semantic token a mapping had to provide.

JavaScript remained behind some declarations temporarily. That created a risk: declarations could lie.

I controlled it with contract tests. Examples compiled against the declarations, and runtime tests exercised the same boundary. A declaration update and implementation update shipped together. Any use of any at the public edge required a named reason and a removal condition.

This approach delivered editor feedback to typed consumers without forcing JavaScript applications to convert. It also revealed awkward APIs. When a prop type required a paragraph of unions and optional fields, the runtime contract was usually just as unclear.

Types became a design pressure on the package surface before they became an implementation statistic.

Invalid combinations became named states

The operation status component originally accepted:

pending?: boolean
failed?: boolean
succeeded?: boolean
timedOut?: boolean
receipt?: object
error?: object

Several combinations were possible and nonsensical. An operation could be pending and succeeded, failed without an error, or timed out and rendered as definitely failed even when the effect's outcome was unknown.

TypeScript made the existing model's weakness difficult to ignore. I replaced flags with a tagged union using syntax available at the time:

type OperationState =
  | { kind: 'pending'; operationId: string; startedAt: string }
  | { kind: 'succeeded'; operationId: string; receipt: Receipt }
  | { kind: 'failed'; operationId: string; error: PublicError }
  | { kind: 'unknown'; operationId: string; lastCheckedAt: string }

The renderer switched on kind. A default branch assigned the remaining value to never, making a newly added state visible at compile time wherever exhaustive handling mattered.

The improvement was not type safety in the abstract. It was an honest domain distinction that the previous API allowed consumers to skip.

JavaScript consumers received runtime validation in development because compile-time types did not protect them. The same state schema informed both layers so the typed and untyped contracts did not drift.

Null checking exposed absence as architecture

Enabling strictNullChecks in new modules produced friction immediately. A dialog return target might no longer exist. A field description was optional. An operation receipt did not exist while an effect was pending. A theme lookup could miss a role.

The earlier JavaScript used truthiness and comments to carry those distinctions. TypeScript required them at use sites.

Not every nullable value became an error. Some absence was legitimate and needed a state model. The return focus strategy, for example, accepted a preferred element and a fallback resolver because deletion could remove the invoker. An operation's receipt lived only in the succeeded state rather than as an optional property on all states.

Other absence indicated a broken contract. A supported theme missing focus-ring should fail construction, not return undefined and hope the component had a fallback. A dialog without a name should produce a development error.

I classified null findings:

legitimate optionality -> model explicitly
state-dependent presence -> move into tagged state
initialization sequencing -> make lifecycle explicit
contract violation -> fail at boundary
compiler uncertainty -> narrow with evidence

The least useful response was !-style assertion—TypeScript's non-null assertion operator existed by then, but using it broadly would move uncertainty out of the type system without proving anything. Assertions stayed near verified DOM or runtime boundaries and carried a comment when the evidence was not obvious.

Null checking paid off when absence gained meaning, not when errors were silenced.

J05N could change internally without requiring every application to compile TypeScript. The published package contained JavaScript, styles, and declaration files.

That boundary had several responsibilities:

  • The emitted JavaScript had to preserve the established module contract.
  • Declarations had to refer only to types consumers could resolve.
  • No source-only path or TypeScript helper could leak into imports.
  • JavaScript consumers needed runtime warnings for consequential invalid states.
  • The package's supported browser target remained unchanged by the migration.

I tested the packed artifact in two fixture consumers: one JavaScript application and one TypeScript application. Each installed the package as a consumer would, rather than importing source through the monorepo path.

The JavaScript fixture caught missing runtime files and assumptions that only typed callers would obey. The TypeScript fixture caught declaration resolution and public inference problems. Both rendered the same behavioral scenarios.

This prevented the repository build from becoming false confidence. A package can type-check internally and still publish an unusable boundary.

Incremental adoption worked because the boundary translated capability without forcing uniform tooling behind it.

Compatibility wrappers became typed debt

J05N had temporary wrappers translating old props and events into newer contracts. In JavaScript, their temporary nature was documented but easy to expand. TypeScript made their ambiguity visible.

A button wrapper accepted both busy and loading. Instead of typing both as optional booleans and preserving invalid combinations, the wrapper accepted an explicit old or new input shape:

type LegacyButtonProps = {
  contract: 'legacy'
  busy?: boolean
  onTap: () => void
}
 
type CurrentButtonProps = {
  contract: 'current'
  loading?: boolean
  onClick: () => void
}

The wrapper translated both into one internal model. The discriminator was verbose for a temporary layer. It prevented ambiguous mixtures and made old usage searchable.

The type also carried no illusion of permanence. Source comments linked the known consumers and removal release. A build report counted contract: 'legacy' uses. When the count reached zero, the entire union branch and adapter tests disappeared.

Typed compatibility can become exceptionally comfortable technical debt. J05N used types to make the old path more visible and constrained, not more pleasant to keep indefinitely.

TypeScript 2.3's JavaScript checking made it possible to gain feedback without renaming a file. Turning it on for the entire repository produced a noisy mixture of useful contract errors, missing third-party declarations, and patterns the checker could not infer.

I used it on selected modules at stable boundaries:

  • Token mapping and theme validation.
  • Pure normalization helpers.
  • Migration scripts whose input records had a defined shape.
  • JavaScript consumers in the package fixture.

JSDoc expressed important parameter and return types where a file was likely to remain JavaScript. Local suppression comments required a reason. A suppression was not a migration strategy; it marked a specific mismatch between current evidence and what the checker could establish.

Some checked JavaScript later moved to TypeScript with little change. Other files remained JavaScript because the checking delivered enough value and conversion offered no additional contract benefit.

This broke the false binary between untyped JavaScript and rewritten TypeScript. The repository could increase verified knowledge without treating extensions as status.

The probe also helped estimate migration. A small checked surface revealed dependency declaration gaps before a conversion plan depended on them.

Third-party types were a trust boundary

Installing declarations for React and other dependencies removed a great deal of manual work. It also introduced another version relationship.

A declaration package could describe a newer library API than J05N actually ran. A community definition could be incomplete or interpret a loose JavaScript contract more narrowly than runtime behavior. Passing compilation meant my code matched the declarations, not necessarily the installed implementation.

I pinned runtime and type package versions together in the lockfile and reviewed upgrades as one change. Where a definition was wrong, a local augmentation or narrow wrapper documented the discrepancy. Broadly editing declarations under installed dependencies would create an invisible fork.

Runtime integration tests remained necessary around DOM focus, browser events, and package output. A type saying an element existed did not prove it remained connected after a React update. A callback signature did not prove the browser delivered events in the assumed order.

Types became high-value evidence about interfaces. They did not become the runtime itself.

This distinction kept the migration from converting compiler confidence into operational overclaim.

It was tempting to type every component with one broad base containing className, style, children, and arbitrary DOM properties. That would make wrappers flexible and allow almost any application need through.

J05N exposed only the surface it intended to maintain. A button accepted its supported action semantics and presentation roles. A field accepted label and relationship content. Escape hatches such as className were allowed where composition genuinely required them, but arbitrary prop spreading was not the default.

The reason was not that extra DOM attributes were inherently unsafe. A broad type makes accidental API permanent. Consumers start depending on internal structure, event ordering, and styling hooks the component cannot change later.

When the HTML platform had the right primitive, J05N preserved its important attributes. Link components did not hide href. Buttons preserved type, disabled, and naming. The wrapper's type did not replace semantic review.

Typing the narrow contract created useful friction. A request for a new prop became a design question: is this a shared responsibility, a composition need, or domain behavior trying to enter the component?

Compiler errors were sometimes the first review comment.

Migration order followed dependency direction

Converting a leaf component that imported many untyped helpers pushed any inward. Converting a low-level helper first did not necessarily improve a public contract. I mapped dependencies and chose seams.

Stable shared models came first. Theme and operation types could be imported by TypeScript and described to JavaScript. Boundary adapters converted unknown runtime input into those models. Components followed once their inputs were meaningful.

The intended direction was:

runtime input
  -> validate or normalize at boundary
  -> typed maintained model
  -> typed component and logic
  -> browser or package output

I did not annotate unverified external data as a trusted interface merely to avoid errors. TypeScript had no unknown type yet, so boundary input often entered as any in a very small adapter and was checked before becoming a maintained type. The unsound area stayed visible and narrow.

This was preferable to letting any flow through the application or writing an interface that asserted a network response always matched.

The migration expanded islands of verified contracts outward from stable seams rather than converting files in alphabetical order.

The strict master option introduced in TypeScript 2.3 made the desired checking posture easier to state, but enabling it across a mixed historical codebase still needed care.

New TypeScript modules used strict checking from their first commit. Converted modules had to meet the same bar before their rename landed. Existing JavaScript did not block the compiler simply because it had not yet crossed that boundary.

I avoided a growing list of per-file exceptions inside TypeScript. If a conversion required dozens of assertions and implicit any escapes, it was not ready or the boundary was wrong. The file could remain checked JavaScript while its dependencies and model improved.

This policy prevented two dialects of TypeScript: new strict code and a large migrated region whose annotations mostly suppressed the checker.

Compiler configuration changes received migration notes because stricter analysis can reveal new errors without runtime code changing. The affected surface and remediation belonged in release planning, especially for declarations consumed outside J05N.

Strictness was a contract for newly claimed territory, not a flag used to shame the unconverted repository.

Some early tests asserted prop object shapes already enforced by the compiler. Those checks added little and distracted from runtime behavior.

I divided evidence:

  • The compiler checked that maintained code constructed valid states and fulfilled interfaces.
  • Runtime validation checked untyped and external boundaries.
  • Unit tests checked transitions and normalization.
  • Browser tests checked DOM semantics, focus, events, and rendering.
  • Package fixtures checked published JavaScript and declarations as consumers saw them.

A dialog type could require an initial-focus strategy. A browser test still needed to prove focus moved after mounting. A theme type could require every role. A contrast test still needed to assess mapped pairs. A tagged operation state could prevent impossible combinations in TypeScript. Runtime parsing still needed to reject invalid JavaScript input.

Types allowed tests to stop enumerating impossible compile-time shapes and spend more attention on temporal and platform behavior.

The migration succeeded when each evidence layer became more specific, not when tests disappeared behind a green type check.

The theme mapping was the first complete TypeScript boundary. Its interface required every semantic token, and the compiler reported that the alternate dark mapping lacked border-control-focus.

The theme had been silently falling back to the default mapping through JavaScript property lookup. On a dark surface, that fallback produced a low-contrast edge visible only in one focused field state. The catalogue overview did not include the combination.

Typing found the missing role before a screenshot did. The fix was not adding an optional marker or default. A supported theme promised to implement the contract, so the mapping gained a deliberate value and a contrast scenario.

This defect clarified the value of the migration. TypeScript had not made the CSS correct. It had made an incomplete mapping impossible to represent as a complete theme inside the typed boundary.

That kind of result justified expanding the approach to operation states and component props.

I published a mixed repository, not a mixed promise

Internally, J05N remained mixed for months. Some modules were strict TypeScript, some checked JavaScript, and some unchanged JavaScript behind typed adapters.

The public package did not expose that history as ambiguity. It shipped one JavaScript runtime contract, declarations for typed consumers, runtime development checks at consequential untyped boundaries, and documentation that described behavior rather than conversion status.

Applications could adopt J05N without adopting TypeScript. A TypeScript application gained type information. A JavaScript application received the same components and behavior. Migration of J05N internals did not become a prerequisite for migration of interface patterns.

This separation kept two changes independent:

J05N implementation learns stronger static contracts
applications adopt J05N behavior through their own path

Combining them would have made every component migration carry a language and build migration. The package boundary allowed progress on both sides at different speeds.

Each converted boundary left temporary artifacts: handwritten declarations, adapters around untyped modules, suppression comments, dual build configuration, and migration tracking.

The plan named their end states. A handwritten declaration disappeared when its implementation became TypeScript and generated declarations matched the public contract. An any boundary narrowed after runtime validation. A compatibility union vanished with its last legacy consumer. A checked JavaScript file either remained intentionally checked or converted; it did not linger in an unexplained halfway status.

I reviewed temporary surfaces by release. The goal was not to remove all JavaScript. It was to remove duplication and unsound bridges whose only purpose was transition.

Several JavaScript files stayed. They were small, stable, and adequately checked at their boundaries. Converting them would have improved the counter more than the system.

The rewrite diagram had promised a clean endpoint defined by file extensions. The incremental path produced a more meaningful endpoint: public contracts were explicit, invalid states narrowed, external input was checked at entry, and temporary compatibility had a removal path.

TypeScript became a boundary tool

The most important change was not that J05N contained TypeScript. It was that types became part of how the system stated and moved decisions.

Tagged states made operation truth explicit. Strict null checking separated legitimate absence from contract failure. Theme interfaces made incomplete mappings fail early. Declaration files carried package intent to typed consumers. Checked JavaScript let existing code gain scrutiny before conversion. Fixture applications verified that the published boundary served both worlds.

None required rewriting every application or every J05N module at once.

The failed proposal imagined value accumulating with the number of converted files and arriving fully at the end. The working migration delivered value at each maintained seam and allowed the repository to remain valid between steps.

TypeScript did not replace runtime validation, browser testing, or design judgment. It made chosen contracts harder to violate silently.

That was enough. J05N did not need a language rewrite. It needed a clearer way to say what crossed a boundary, which states were valid, and where uncertainty still remained. TypeScript became useful when it served those questions instead of becoming the project itself.