Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/consumer-declaration-emit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@btravstack/entity": minor
---

Fix: a downstream library that emits its own declarations can now use the
package at all.

`class X extends Entity("X")(...)` failed with `TS4020: 'extends' clause of
exported class has or is using private name 'CtorKey'` (and `'BaseInstance'`)
for any consumer compiling with `declaration: true` — which is every published
TypeScript library. The package's own build never surfaced it, because its
`tsc` pass is `noEmit`.

The construction seal now uses an exported-but-unconstructable
`ConstructionKey` instead of a module-private `unique symbol`: a `unique
symbol` in computed-key position cannot be named across a module boundary even
when exported, while an ordinary property whose _type_ is an exported class
can. `ConstructionKey`, `Sealed` and `BaseInstance` are exported as types so
the emitted declarations can reference them; none is constructible and none is
meant to be used directly.

The seal is unchanged in strength — `new SomeEntity(...)` is still a compile
error, and `ConstructionKey` cannot be forged structurally.
40 changes: 40 additions & 0 deletions packages/entity/consumer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* A stand-in for a downstream *library*: it exports an entity subclass **and**
* emits its own declarations. That combination is what regressed before — a
* module-private `unique symbol` in the seal made every such consumer fail
* with TS4020, while this repo's own build (which does not emit declarations
* through `tsc`) stayed green.
*
* `tsconfig.consumer.json` compiles this, and both of its overrides are load
* bearing: it turns `noEmit` off because declaration emit is what surfaces a
* leaked private name, and points `paths` at `dist/*.d.mts` so this exercises
* the published types rather than `src`.
*/
import { Entity, computed } from "@btravstack/entity";
import type { ConstructionKey } from "@btravstack/entity";
import { z } from "zod";

const OrgId = z.uuid().brand("OrgId");
const Slug = z.string().min(1).brand("Slug");
const Upper = z.string().min(1).brand("Upper");

export class Organization extends Entity("Organization")(
{ id: OrgId, slug: Slug },
{
immutable: ["id"],
computed: { shout: computed(Upper, (d) => d.slug.toUpperCase() as z.infer<typeof Upper>) },
},
) {}

/** The statics must still yield the subclass, not the structural base. */
export const load = (raw: unknown): Organization => Organization.make(raw).getOrThrow();

/** Nesting must still work from outside the package. */
export const Aggregate = z.object({ owner: Organization.instance });

// @ts-expect-error construction stays sealed for a consumer
new Organization({ id: "x" as never, slug: "y" as never });

// @ts-expect-error the construction key cannot be forged structurally
const forged: ConstructionKey = {} as { seal: never };
void forged;
2 changes: 1 addition & 1 deletion packages/entity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"dev": "tsdown src/index.ts --format cjs,esm --dts --watch",
"test": "vitest run",
"test:types": "tsc --noEmit -p tsconfig.test-d.json",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json && tsc -p tsconfig.consumer.json"
},
"devDependencies": {
"@btravstack/tsconfig": "catalog:",
Expand Down
3 changes: 3 additions & 0 deletions packages/entity/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export { Entity, type CreateInput, type Input, type Output, type Patch } from "./entity.js";
export { computed, type ComputedField } from "./computed.js";
export { InvalidEntity } from "./errors.js";
// Exported only so a consumer's emitted declarations can name them — none of
// the three is part of the API you write against. See `Sealed` in types.ts.
export type { BaseInstance, ConstructionKey, Sealed } from "./types.js";
39 changes: 26 additions & 13 deletions packages/entity/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,21 +154,34 @@ export type UpdateInputShapeOf<
};

/**
* A type-level construction lock. `CtorKey` never leaves this module, so no
* outside code can name it and therefore cannot produce a value assignable to
* `Sealed<D>`. This closes the constructor without a runtime check, which
* `unthrown/no-throw` forbids.
* A type-level construction lock: no outside code can produce a value
* assignable to `Sealed<D>`, so `new SomeEntity(...)` does not compile. It
* closes the constructor without a runtime check, which `unthrown/no-throw`
* forbids.
*
* A literal `protected constructor` was measured and does not work: TypeScript
* refuses to assign a protected-constructor class to any construct signature,
* so the statics could only return the base class rather than the subclass.
* `ConstructionKey` is **exported but unconstructable** — a private
* constructor and a private field make it unforgeable structurally, and it has
* no runtime existence at all. Exporting it is what lets a *consumer* compile:
*
* Depends on `declaration: false` in the shared tsconfig: with declaration
* emit on, TS4020 rejects an exported class whose `extends` clause uses this
* private name.
* A `declare const CtorKey: unique symbol` kept module-private was measured to
* break every downstream library that emits declarations —
* `TS4020: 'extends' clause of exported class 'Organization' has or is using
* private name 'CtorKey'` — because a `unique symbol` in computed-key position
* cannot be named across a module boundary even when exported. An ordinary
* named property whose *type* is an exported class can, so the emitted `.d.ts`
* references it as `import("@btravstack/entity").Sealed<…>`.
*
* A literal `protected constructor` was measured and does not work either:
* TypeScript refuses to assign a protected-constructor class to any construct
* signature (TS2684), so the statics could only return the base class rather
* than the subclass. `private` is worse still — TS2675, the declaration form
* `class X extends Entity("X")(...)` stops compiling outright.
*/
declare const CtorKey: unique symbol;
export type Sealed<D> = D & { readonly [CtorKey]: true };
export declare class ConstructionKey {
private constructor();
private readonly seal: never;
}
export type Sealed<D> = D & { readonly __constructionKey: ConstructionKey };

/**
* The instance-side shape every entity's `Base` class structurally has: the
Expand All @@ -185,7 +198,7 @@ export type Sealed<D> = D & { readonly [CtorKey]: true };
// converting this one to a `type` reintroduces exactly the TS2526 this
// package's other `interface`-avoidance already worked around elsewhere.
// oxlint-disable-next-line typescript/consistent-type-definitions
interface BaseInstance<
export interface BaseInstance<
S extends Fields,
A extends Fields,
I extends readonly (keyof OutputOf<S, A>)[],
Expand Down
14 changes: 14 additions & 0 deletions packages/entity/tsconfig.consumer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"extends": "@btravstack/tsconfig/base.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"declarationMap": false,
"emitDeclarationOnly": true,
"outDir": "./node_modules/.consumer-check",
"rootDir": "./consumer",
"types": ["node"],
"paths": { "@btravstack/entity": ["./dist/index.d.mts"] }
},
"include": ["consumer/**/*"]
}
Comment thread
btravers marked this conversation as resolved.
2 changes: 1 addition & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"format": {},
"typecheck": {
"dependsOn": ["^build"]
"dependsOn": ["build"]
},
"test:types": {
"dependsOn": ["^build"]
Expand Down
Loading