Skip to content

EPBDS-16211 E4: generate the package's declarations from the source - #146

Merged
AlexSamBY merged 1 commit into
masterfrom
ts-e4
Oct 1, 2026
Merged

AlexSamBY merged 1 commit into
masterfrom
ts-e4

Conversation

@AlexSamBY

Copy link
Copy Markdown
Member

What changes

E4: the package's declarations are generated from the source. The hand-written src/library/types folder is gone. The types a host gets are now the types the component is checked against.

The contract is an ordinary module

  • src/library/contract.ts holds every published type as an export. I moved them with git mv from types/UIRender.tsx, where they were members of a declare namespace. Each declaration's text is unchanged apart from export and the indentation, JSDoc included.
  • The component is typed with it. main.tsx's Render takes UIRenderProps<Data> and returns React.ReactElement | null, written out.
  • Before, nothing tied the two together. The component's props were a separate, looser type, RenderProps, with every key unknown.

npm run gen-ts is now a script

scripts/gen-ts.js runs tsc on tsconfig.build.json, which now extends the typecheck config. tsc emits the declarations of the program rooted at main.tsx into a staging directory. The script publishes two files:

  • dist/contract.d.ts, the contract's declaration, as tsc wrote it;
  • dist/index.d.ts, written from main.tsx's declaration. It holds the component renamed UIRender, a namespace that re-exports each contract type with export import, and export =.

The script writes the second file itself because export = cannot be in the source: Babel rejects it. It also deletes any other dist/*.d.ts. webpack keeps declaration files across its clean, so the old dist/UIRender.d.ts would otherwise have shipped.

The script refuses what must not be published:

  • an import other than react and the contract, since a host's compiler would have to resolve it;
  • a value in the contract, since the runtime exports nothing but the component;
  • a default export that is not a function. A namespace of types merges with a function and not with a const; I measured it, and the const form fails with TS2451;
  • anything else in main.tsx's declaration.

scripts/__tests__/gen-ts.contract.test.js is new, with 15 tests. It pins each refusal on a declaration written in the test, without running tsc. It also counts the real contract's 27 types, so adding a public type shows up in review.

The checker now holds the engine to the contract where they meet

  • MetaProblem.severity was string in the validator. It is now the two values the validator produces. Loosening it back fails main.tsx, which hands the problems to a host typed against the contract.
  • The props reach the engine through one documented cast. The contract keeps a host's Data open, while the form layer types initialValues as a record. I checked that the cast is needed and that a single as suffices.

Golden diff, against master's declarations

The baseline is master's emitted dist/*.d.ts: the Phase 0.6 contract plus the changes accepted since.

  • The same 27 names, on both sides.
  • Every type is identical, both by an Eq identity check and by mutual assignability. The two generics were checked at their default and at a sample type.
  • The callable is identical, and so is its React.ComponentProps.
  • The check is not vacuous. A control that widened one prop failed 7 of the checks, and one that narrowed the return type failed 2.
  • Textually, each declaration is the same apart from export. The one intended change is the component's JSDoc. It used to say only that the entry is the function itself; it now also says what the function renders.

Probes on the real source

Each one was reverted, and the files were compared byte for byte afterwards.

  • A prop added to Render's signature reached dist/index.d.ts.
  • Loosening MetaProblem.severity to string failed the typecheck at main.tsx.
  • Loosening items in the contract failed typecheck:contract with TS2578.
  • A value exported from the contract stopped gen-ts.

What else moved

  • The agreement table is now src/library/contract.agreement.ts. It imports the contract as a module, so npm run typecheck covers it. typecheck:contract runs it alone and extends the same config, so the CI step keeps its name and meaning.
  • The contract suite reads the vocabularies from contract.ts.
  • dist/contract.d.ts is a required file of the tarball.
  • The coverage log no longer reports two failures on every run. Jest could not collect coverage from UIRender.tsx or index.ts, because Babel rejected export =. The agreement file, a compile-time test that never runs, is left out of coverage.
  • Docs: CLAUDE.md documents gen-ts and the contract, and docs/UPGRADE-PLAN.md records E4 and closes its items.

Measured

master this PR
published declaration files index.d.ts, UIRender.d.ts index.d.ts, contract.d.ts
published types 27 27, all identical
consumers, @types/react 16 / 17 / 18 / 19 × interop / CommonJS 8 of 8 8 of 8
corpus, 38 examples: commits at mount / translate calls 107 / 1012 107 / 1012
on an edit: commits / translate calls 27 / 64 27 / 64
DOM after mount and after an edit identical, all 38
dist/index.js 311,527 bytes 311,539 bytes

The 12 bytes come from main.tsx's restructuring. Both bundles were built from git archive copies in directories of the same name length.

Gates

All exited 0, run on this branch:

  • lint:js, lint:css, typecheck, typecheck:contract;
  • docs:props:check, docs:views:check, css:fixture:check;
  • test:env-flags, build;
  • test:coverage, thresholds included;
  • test:pack, test:pack:peers, test:types;
  • test:react16, test:react17, test:react19, 2857 tests each, with no worker crash;
  • test:e2e, 41 tests;
  • the manifest contract.

The suite grew by the generator's 15 tests, from 2842 to 2857.

🤖 Generated with Claude Code

The published types were written by hand in src/library/types, beside
a component that did not use them. They are now generated.

- src/library/contract.ts holds every published type as an export,
  moved with git mv from the namespace in types/UIRender.tsx. Each
  declaration's text is unchanged apart from `export`.
- main.tsx types the exported component with UIRenderProps<Data> and
  writes out its return type, React.ReactElement | null.
- npm run gen-ts is scripts/gen-ts.js. tsc emits the declarations of
  the program rooted at main.tsx into a staging directory, and the
  script publishes dist/contract.d.ts as tsc wrote it. It writes
  dist/index.d.ts from main.tsx's declaration: the component renamed
  UIRender, a namespace re-exporting each contract type, and
  `export =`, which the source cannot hold because Babel rejects it.
- The script refuses an import other than react and the contract, a
  value in the contract, a default export that is not a function, and
  anything else in main.tsx's declaration. A new suite pins each
  refusal and counts the contract's 27 types.
- The checker now holds the engine to the contract where they meet:
  MetaProblem.severity is the two values the validator produces, not
  string.

The golden diff, against master's declarations: the same 27 names,
every type identical by an Eq identity check and by mutual
assignability, and the callable identical. Two controls, one widening
and one narrowing, each failed the check. The one intended change is
the component's JSDoc.

Measured: consumers compile on @types/react 16, 17, 18 and 19, with
both import styles. The corpus is identical. dist/index.js went from
311,527 to 311,539 bytes.
@AlexSamBY
AlexSamBY merged commit 30e815d into master Oct 1, 2026
5 checks passed
@AlexSamBY
AlexSamBY deleted the ts-e4 branch October 1, 2026 12:55
AlexSamBY added a commit that referenced this pull request Oct 1, 2026
## What changes

**This finishes the TypeScript migration.** It meets every clause of the
definition of done in `docs/UPGRADE-PLAN.md` §9.6:
- `src/core` + `src/library` are TypeScript under `strict`. That was E2
and E3, the last of it in #144.
- `allowJs` is off. That is this PR.
- The declarations are generated from the source. That was E4, #146.
- `propTypes` and `prop-types` are gone. That was E5, #145.

The demo and the test suites stay JavaScript, as the definition of done
allows.

### `allowJs` is off

- **The typecheck program is the TypeScript and nothing else:** 418
files before, 137 now. The 281 that left were 234 `.js`, 9 `.jsx` and 36
`.json` files that tsc resolved but never checked, plus the two guard
files deleted below.
- **A `.ts` file that imports a `.js` module now fails** with TS7016. I
measured this with a probe file, then deleted it. During the migration
this was `allowJs: true` with `checkJs: false`, so that a converted file
could import an unconverted one.
- **The typecheck takes 1.56 s, down from 1.80 s**, the median of three
runs each.
- `tsconfig.build.json` loses its own `allowJs: false`, which it now
inherits.

### The E0 guard is deleted

`src/toolchain/` was a two-file fixture. It proved that the checker sees
`.ts` and that Babel strips it. Its header asked for its deletion "once
real converted modules cover the same ground", and they do: the
product's TypeScript is built by all three pipelines and imported by
every suite. No `.test.ts` is left, because the tests are JavaScript.

### Three casts that still gave the migration as their reason

**`PopupContent` re-typed the renderer because it was "still
JavaScript".** I removed the cast, and the checker found what it had
been hiding:
- the popup hands `Render` a `null` `relativePath` and `relativeIndex`;
- `Render` passes them on to its children, because it tests `!==
undefined`;
- `Render`'s prop types said neither could be `null`.

They now allow `null`, and the cast is gone. No behaviour changed.

**`Data` and `ToggleField` called their casts `UnconvertedComponent`.**
Both still need a cast, for reasons of their own:
- a registry slot that `utils` types `unknown`, because it sits below
the layer that fills it;
- a nested document's `initialValues`, which the form layer types as a
record and `Data` as `unknown`.

The type is now `OpenComponent`, and the comments give those reasons.
`Data`'s import is renamed from `UIRenderWithUISetupJs` to
`DocumentWithOwnForm`. Its `as unknown as` cast was double; I checked
that a single `as` suffices.

### Docs

- `CLAUDE.md` says the migration is complete, and that a `.ts` file
cannot import JavaScript.
- `docs/UPGRADE-PLAN.md` marks the definition of done as met and records
the guard's deletion.
- The CI comment on the Typecheck step no longer describes unconverted
JavaScript.

## Measured

| | master | this PR |
|---|---|---|
| typecheck program | 418 files | 137 files |
| `npm run typecheck`, median of 3 | 1.80 s | 1.56 s |
| corpus, 38 examples: commits at mount / `translate` calls | 107 / 1012
| 107 / 1012 |
| on an edit: commits / `translate` calls | 27 / 64 | 27 / 64 |
| DOM after mount and after an edit | | identical, all 38 |
| `dist/index.js` | 311,539 bytes | 311,535 bytes |

The 4 bytes are `PopupContent`'s alias. Both bundles were built from
`git archive` copies in directories of the same name length.

## Gates

All exited 0, run on this branch:
- `lint:js`, `lint:css`, `typecheck`, `typecheck:contract`;
- `docs:props:check`, `docs:views:check`, `css:fixture:check`;
- `test:env-flags`, `build`;
- `test:coverage`, thresholds included;
- `test:pack`, `test:pack:peers`, `test:types`;
- `test:react16`, `test:react17`, `test:react19`, 2855 tests each, with
no worker crash;
- `test:e2e`, 41 tests;
- the manifest contract.

The suite went from 2857 to 2855 tests: the two were the deleted guard's
own.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant