Skip to content

@objectstack/spec's root entry does not re-export three types its own public API's inferred types mention — every consumer inferring through defineStack hits TS2883 #11350

Description

@os-sam

Split out of #10868, which was repaired at the consumer (nine annotated default exports). This card is the producer-side half the triage on #10868 asked for as "repair (2)", restated from measurement — the measurement contradicts the shape the triage assumed, and the remaining repair needs a decision about the published surface, so it is filed rather than taken.

What the measurement showed (on main @ 8bdd95535)

The triage on #10868 read the defect as "@objectstack/spec leaks hash-named internal chunks into inferred types" and prescribed a fix in spec's build (stable dts entry points instead of hashed chunk paths). Three measurements say the chunk naming is a symptom, not the cause:

  1. tsup gives no control over it.tsup@8.5.1 hard-codes the dts pass's output as chunkFileNames: '[name]-[hash].d.ts' (dist/rollup.js, getRollupConfig); there is no dts.* option, and no manualChunks seam, that reaches it.

  2. Removing chunking entirely does not fix the class. A real single-entry dts build (root entry alone, so rollup has one input and cannot split) produced one self-contained 2.77 MB index.d.ts with zero chunk references. Compiling packages/plugins/plugin-audit/scripts/i18n-extract.config.ts against it did not go green — TS2883 became TS4082, and one additional name leaked that the chunked build had not surfaced:

    scripts/i18n-extract.config.ts(24,1): error TS4082: Default export of the module has or is using private name 'BaseValidationRuleShape'.
    scripts/i18n-extract.config.ts(24,1): error TS4082: Default export of the module has or is using private name 'StateNodeConfig'.
    
  3. Making the root entry export the names fixes it, with the hashed chunks untouched. Appending export type { FormFieldInput, NavigationItemInput, StateNodeConfig }; to the built dist/index.d.ts took the same compile from 3 errors to 0.

A fourth reading is the control: when the repro program happened to also contain files importing @objectstack/spec/ui, the errors vanished by themselves — because the subpath entry that does export these names was then in the program for tsc to name them through.

The actual invariant that is broken

FormFieldInput, NavigationItemInput and StateNodeConfig are already publicapi-surface/ui.json and api-surface/automation.json record all three, and dist/ui/index.d.ts / dist/automation/index.d.ts re-export them. They are simply not on the root entry, and the root entry is where defineStack lives.

defineStack returns ObjectStackDefinition, which the root entry does export — but it is declared z.input<typeof ObjectStackDefinitionSchema>, a generic instantiation the declaration emitter does not preserve as an alias. So an un-annotated export default defineStack(...) is emitted as the structural expansion, that expansion mentions the three names, and tsc must reach for the file that physically declares them: an internal dist chunk it cannot address through the package's exports map. Hence "likely not portable".

So the rule the producer is breaking is: a type that appears structurally in an entry's public declarations must be nameable from that same entry. Any consumer that infers a type through a root-entry function is exposed, not just the nine i18n-extract configs.

Why this needs a decision rather than a patch

The repair that measurably works is additive on the published surface — three type re-exports on the root entry, so api-surface/root.json grows by three rows. #10868's dispatch made that an explicit stop-and-report, and it is a contract question rather than a build question:

  • Real business need — the pull is real and already measured: nine build-time configs hit it today, and the control above shows the trigger is "infer a type through a root-entry function", which is the ordinary way defineStack is used in a project's objectstack.config.ts. Not speculative surface.
  • Long-term soundness — additive, and it makes the root entry self-consistent instead of accreting per-consumer annotations. The alternative (annotate at every consumer, as 8 packages' scripts/i18n-extract.config.ts fail to type-check: TS2883 on an inferred default naming @objectstack/spec's hashed dist chunks #10868 did) is the pattern that scales badly: each new consumer rediscovers it.
  • Hard to get wrong for AI-written code — an AI-authored config that hits TS2883 gets a diagnostic naming a hash that changes on every rebuild; nothing about it points at the real cause. Making the names reachable removes the class rather than teaching everyone the workaround.
  • Startup scope discipline — the counter-argument, and it is real: these are Input variants of internal-ish shapes, and publishing them on the root entry is a promise to keep them. A narrower option is to publish only what is measured to leak.

Options as they stand:

  • A. Re-export the three names from src/index.ts. Measured to work; api-surface/root.json +3 rows; needs gen:api-surface. Leaves BaseValidationRuleShape (finding 2 above) unaddressed unless it also leaks in some other program.
  • B. Do nothing at the producer and keep annotating consumers, as 8 packages' scripts/i18n-extract.config.ts fail to type-check: TS2883 on an inferred default naming @objectstack/spec's hashed dist chunks #10868 did. Zero surface change; the class stays open and the next consumer pays.
  • C. Add a gate instead — refuse a public entry .d.ts that imports a type from a hash-named chunk without re-exporting it, so the class cannot silently grow, and repair each hit as it is found. Bigger, and it needs A (or per-hit equivalents) to go green in the first place.

Recommendation: A, with C as the follow-up that keeps it closed. A is the only measured fix, and the names it publishes are already published one subpath away — the surface promise is nearly already made. But it is a public-surface addition on the most-imported entry in the repo, so it wants the maintainer's call, not a dev's.

Repro

pnpm --filter '@objectstack/plugin-audit^...' build
# a tsconfig extending the package's own, rootDir neutralised, include ["scripts/**/*"]cd packages/plugins/plugin-audit && npx tsc --noEmit -p tsconfig.repro.json

(with the annotation from #10868 reverted on that one file — it is green as it stands on main).

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions