Skip to content

feat!: collapse the public surface onto Entity - #24

Merged
btravers merged 3 commits into
mainfrom
feat/entity-namespace
Aug 7, 2026
Merged

feat!: collapse the public surface onto Entity#24
btravers merged 3 commits into
mainfrom
feat/entity-namespace

Conversation

@btravers

@btravers btravers commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Everything you write against now hangs off the builder. A bare computed is too generic to take from a consumer's import scope — it collides outright with Vue, MobX, Angular signals and Solid — and this repo had already made that call once, for union, and written the reasoning into entity.ts:

Grouped under Entity rather than exported loose: union alone is too generic a name to take from a consumer's import scope […]

The convention was never "standalone by default". It is a name-pollution test, and applying it consistently leaves one name.

-import { Entity, computed } from "@btravstack/entity";
-import type { Output } from "@btravstack/entity";
+import { Entity } from "@btravstack/entity";

 class Person extends Entity("Person")(
   { first: First, last: Last },
-  { computed: { fullName: computed(FullName, (d) => …) } },
+  { computed: { fullName: Entity.computed(FullName, (d) => …) } },
 ) {}

-type Row = Output<typeof Person>;
+type Row = Entity.Output<typeof Person>;
before after
computed Entity.computed
InvalidEntity Entity.InvalidEntity
Input Output CreateInput Patch Entity.Input
ComputedField Entity.ComputedField
EntityUnion Entity.Union — the stutter only existed to disambiguate a top-level name

No deprecated top-level aliases are kept, per the repo's "resist convenience aliases" rule. Breaking, at 0.1.0, so a minor bump.

Mechanism

Entity stays a function declaration merged with a type-only export declare namespace Entity. Values attach as expando properties, as Entity.union already did. This is forced rather than chosen: export const Entity = Object.assign(fn, {…}) cannot merge with a namespace.

The *Src aliases are load bearing

Writing the obvious thing —

export declare namespace Entity {
  export type ConstructionKey = import("./types.js").ConstructionKey;
}

— emits type ConstructionKey = ConstructionKey into dist/index.d.mts. tsdown's dts bundler collapses the import to a bare name that resolves to the member itself. It compiles. The type just degenerates, and in the spike that silently voided the construction seal; the only signal was the consumer fixture's @ts-expect-error on a forged key going unused. Each source type is therefore imported under a distinct *Src alias, with the measurement recorded as a comment.

Two things the design didn't survive

Both caught by the gate rather than by review, and both written into the spec's Implementation notes.

index.ts exports four names, not one. BaseInstance, ConstructionKey and Sealed must stay top-level. Moving them behind Entity built and typechecked fine, then failed the consumer pass:

consumer/index.ts(26,35): error TS4020: 'extends' clause of exported class
  'Organization' has or is using private name 'BaseInstance'.
consumer/index.ts(26,14): error TS4094: Property 'seal' of exported anonymous
  class type may not be private or protected.

A downstream library compiling with declaration: true emits the underlying type name, not the namespace path aliasing it, so unexported they are private names — the same regression types.ts already documents for ConstructionKey. They are exported both ways now. The rule is "one name you write against", not "one export".

Entity.InvalidEntity is a re-export, not a type alias. Declaring it as a type member stops expando inference supplying the value, so the runtime assignment fails with TS2339. export { InvalidEntity } inside the namespace carries both meanings. computed and union are unaffected — no type member shares their name.

Verification

Full gate green: format --check, lint, typecheck (all three passes), test (120 passed), knip, build.

Three checks specific to this change:

  • Consumer pass reports zero diagnostics, including no TS2578: Unused '@ts-expect-error'. consumer/index.ts now names every namespace member, because a degenerated member is only visible as a directive going unused.
  • Emitted d.mts scanned programmatically for self-referential aliases — none. Top-level exports are exactly { type BaseInstance, type ConstructionKey, Entity, type Sealed }.
  • Every documented spelling typechecked against the built d.mts via a scratch module in the consumer project — the helper types, Entity.computed, Entity.InvalidEntity as both value and type, P.tag("InvalidEntity"), and the repository signature from persist-and-rehydrate.md. Not committed.

Also

  • CLAUDE.md: records the new export rule and the *Src constraint; corrects the stale claim that shape() is "the only sanctioned way to build a domain object" — it is internal and not exported at all.
  • docs/superpowers/specs/2026-08-07-entity-namespace-design.md carries the design and the two corrections.

🤖 Generated with Claude Code

Benoit Travers and others added 3 commits August 7, 2026 13:00
Records the decision to export a single name, the namespace-merge mechanism,
and the emit constraint a spike turned up: a namespace member that shares a
name with the type it aliases is emitted by tsdown's dts bundler as a circular
self-alias. That compiles, so it fails silently -- in the spike it voided the
construction seal, detected only because the consumer fixture's
`@ts-expect-error` went unused.

Not yet implemented; sequenced behind #23, which edits the same docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#23 merged as 8c2485b and this branch is rebased onto it, so the docs pass
starts from the corrected text rather than racing it. Records the one thing
that merge added to this change's scope: the shared imports preamble it
introduced appears in eight files, each of which collapses to `import
{ Entity }`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`computed`, `InvalidEntity` and every public type now hang off the builder.
A bare `computed` is too generic to take from a consumer's import scope -- it
collides outright with Vue, MobX, Angular signals and Solid -- and this repo
had already made that call once, for `union`, and written down the reasoning.
Applying the same test consistently leaves one name to write against.

`EntityUnion` becomes `Entity.Union`; the stutter only existed to disambiguate
a top-level name.

Two things the design did not survive contact with.

`index.ts` still exports `BaseInstance`, `ConstructionKey` and `Sealed` at the
top level. Hiding them behind the namespace builds and typechecks, then fails
the consumer pass with TS4020: a downstream library compiling with
`declaration: true` emits the underlying type name, not the namespace path
aliasing it, so unexported they are private names. That is the same regression
`types.ts` already documents for `ConstructionKey`.

`Entity.InvalidEntity` is a namespace re-export rather than a type alias.
Declaring it as a type stops expando inference supplying the value, and the
runtime assignment fails with TS2339; `export { InvalidEntity }` carries both
meanings.

The `*Src` aliases in entity.ts are load bearing and commented as such. A
namespace member sharing a name with the type it aliases is emitted by tsdown's
dts bundler as a circular self-alias, which compiles and then silently
degenerates the type -- measured to void the construction seal, detected only
because a consumer `@ts-expect-error` went unused.

BREAKING CHANGE: `computed`, `InvalidEntity`, `Input`, `Output`, `CreateInput`,
`Patch`, `ComputedField` and `EntityUnion` are no longer top-level exports. Use
`Entity.computed`, `Entity.InvalidEntity`, `Entity.Input`, `Entity.Output`,
`Entity.CreateInput`, `Entity.Patch`, `Entity.ComputedField` and `Entity.Union`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 7, 2026 11:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Collapses @btravstack/entity’s public API surface onto the Entity builder (values and types), to avoid generic-name collisions in consumer import scopes and to apply the repo’s “one concept, one name” rule consistently.

Changes:

  • Move public helper values/types behind Entity via expando properties + export declare namespace Entity (e.g., Entity.computed, Entity.InvalidEntity, Entity.Output, Entity.Union).
  • Update all internal tests, consumer fixture, and docs to use the new spellings.
  • Preserve BaseInstance, ConstructionKey, and Sealed as top-level type exports (while also exposing them under Entity.*) for downstream declaration: true compatibility.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated no comments.

Show a summary per file
File Description
README.md Update example import and computed usage to Entity.computed.
packages/entity/src/union.ts Export UnionMember type so Entity.Union can express constraints.
packages/entity/src/nesting.spec.ts Update test imports and computed call site to Entity.computed.
packages/entity/src/index.ts Export only Entity as the main entry point; keep top-level type escape hatches.
packages/entity/src/extend.spec.ts Update test imports and computed call site to Entity.computed.
packages/entity/src/entity.ts Attach computed/union/InvalidEntity onto Entity; introduce merged Entity namespace for public types.
packages/entity/src/entity.test-d.ts Update type-level assertions to Entity.* helper types and Entity.computed.
packages/entity/src/contract.spec.ts Update computed usage to Entity.computed.
packages/entity/src/computed.spec.ts Update computed usage to Entity.computed.
packages/entity/README.md Update package README examples to Entity.computed and single import.
packages/entity/consumer/index.ts Update consumer fixture to validate all Entity.* members against built d.ts output.
docs/superpowers/specs/2026-08-07-entity-namespace-design.md Add design/spec doc capturing the mechanism and constraints.
docs/reference.md Update reference docs to Entity.computed, Entity.InvalidEntity, and Entity.* helper types.
docs/how-to/test-domain-logic.md Update shared imports preamble to import { Entity }.
docs/how-to/persist-and-rehydrate.md Update helper type example to Entity.Output.
docs/how-to/model-an-aggregate.md Update imports and computed example to Entity.computed.
docs/how-to/http-contract.md Update shared imports preamble to import { Entity }.
docs/explanation.md Update shared imports preamble to import { Entity }.
CLAUDE.md Document the new “write against Entity” rule and the *Src alias constraint.
.changeset/entity-namespace.md Add changeset documenting the breaking change and new spellings.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@btravers
btravers merged commit 6658b89 into main Aug 7, 2026
14 checks passed
@btravers
btravers deleted the feat/entity-namespace branch August 7, 2026 11:52
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.

2 participants