Skip to content

feat!: forbid subclassing an entity class - #8

Merged
btravers merged 1 commit into
mainfrom
feat/forbid-entity-subclassing
Aug 6, 2026
Merged

feat!: forbid subclassing an entity class#8
btravers merged 1 commit into
mainfrom
feat/forbid-entity-subclassing

Conversation

@btravers

@btravers btravers commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Supersedes #2, which made subclassing correct. This makes it illegal, so
that case can no longer arise and instance.ts stays at main's simpler
self-overwriting getter.

The rule

One extends is the declaration form. Anything deeper defects:

class Sub extends Organization {}
Sub.decode(raw); // Defect

Using the builder's return directly, without extends, is unaffected — both
are covered by tests.

Why a Defect, not a throw or an InvalidEntity

A bare throw in the constructor would escape decode() and break the
Result contract. It routes through
fromThrowable(..., (cause, defect) => defect(cause)) instead — unthrown
exposes no direct Defect constructor, and this is the repo's sanctioned
defect path. Not InvalidEntity, because this is a bug in domain code rather
than bad caller input, which is the same line the existing add-output defect
draws.

Side benefit: constructor throws in general now become defects instead of
escaping. TypeError: Cannot redefine property — what you get if a subclass
redeclares a data field — was previously thrown straight out of decode().

Runtime only

TypeScript has no final, and a private/protected constructor cannot
express "extendable once". Both measured:

  • privateTS2675: Cannot extend a class 'Base' — the declaration form
    stops compiling.
  • protectedTS2674 (seals, good) but TS2684: Cannot assign a 'protected' constructor type to a 'public' constructor type — the statics stop returning
    the subclass.

So class Sub extends Organization {} compiles and reports on first
construction. Closing that gap needs a lint rule; see below.

Fallout

  • equals: the sibling-subclass rule documented in chore: cover the published Node floor in CI and record two invisible couplings #6 is removed — two
    subclasses of one entity can no longer both exist.
  • The OrgWithCache examples in the README and tests move from a subclass to a
    first-level class body, which is the pattern being steered to anyway. What
    they pin is unchanged: class-body fields stay writable (why
    Object.freeze(this) cannot be used) and are absent from toJSON().
  • New README section, and a CLAUDE.md convention entry.

Not done: the oxlint rule

A lint rule is what would make this compile-time. Deliberately not in this PR:
detecting class Sub extends SomeEntity {} across files needs type
information, and I have not confirmed oxlint's JS plugin API exposes a
checker. Same-file cases are trivially syntactic. It also belongs in
@btravstack/oxlint, since it is a rule about consuming this library.

Gate

format --check, lint, typecheck (both passes), test (90, 9 files),
knip, build — all green.

Copilot AI lite review requested due to automatic review settings August 6, 2026 20:54
@btravers
btravers merged commit 3ca1111 into main Aug 6, 2026
1 check passed
@btravers
btravers deleted the feat/forbid-entity-subclassing branch August 6, 2026 21:06
@btravers
btravers removed the request for review from Copilot August 6, 2026 21:29
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