Skip to content

feat!: make the entity class itself a zod schema, removing instance - #19

Merged
btravers merged 5 commits into
mainfrom
feat/entity-is-a-schema
Aug 7, 2026
Merged

feat!: make the entity class itself a zod schema, removing instance#19
btravers merged 5 commits into
mainfrom
feat/entity-is-a-schema

Conversation

@btravers

@btravers btravers commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Your idea, and it is better than either alternative I proposed. .instance is
gone; the class is the schema.

// before
class Order extends Entity("Order")({ customer: Customer.instance }) {}
z.object({ owner: Organization.instance });
fromSchema(Organization.instance);

// after
class Order extends Entity("Order")({ customer: Customer }) {}
z.object({ owner: Organization });
fromSchema(Organization);

z.object({ owner: Organization }) never worked before — the README had a
paragraph explaining why it could not. That paragraph is gone.

Slots, not methods

The class carries _zod and ~standard and nothing else of zod's. Verified
that the minimal pair is sufficient for zod's shape constraint at both the
runtime and type level, which matters: the full ZodType surface would put a
throwing .parse() on every entity, beside the make that returns a
Result. That is the thing this package exists to avoid.

So Organization.parse(raw) does not exist — use make — and wrapping goes
through zod's function forms. All verified working:

z.optional(C) · z.nullable(C) · z.array(C) · z.record(k, C) · fromSchema(C)
z.toJSONSchema(C, { io: "output" })   → still throws, by design

~standard returns, reversing #16

I removed it there arguing it was a convenience alias creating two spellings,
and that was right at the time. Here it is load-bearing — part of what makes
the class a genuine schema rather than a shortcut to one. Same property,
opposite conclusion, different reason.

Also

  • Fields widens from z.ZodTypeAny to z.core.$ZodType, since an entity
    class carries slots rather than methods. Anything zod accepts in an object
    shape is accepted as a field.
  • Entity.union(...) gets the same slots, so a union composes and nests
    identically — and its internal dispatch now goes through make rather than
    a parse method, because members are classes now.
  • attachInstanceattachSchema, memoising per receiver in a WeakMap so
    a schema built from a subclass still yields that subclass.

The consumer fixture earned its keep

It failed on Organization.instance and had to be updated — exactly the
regression signal #13 added it for, on a change that had nothing to do with
declaration emit.

Gate

format --check, lint, typecheck (three passes), test (120, 11 files),
knip, build — all green.

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

This PR makes Entity(...) subclasses directly usable as zod schemas by attaching only the _zod and ~standard slots to the class itself, removing the need for .instance. This aligns composability (z.object({ owner: Organization })) with the entity’s non-throwing make API by avoiding exposing zod’s full ZodType method surface (e.g. .parse()).

Changes:

  • Remove .instance as the composable surface; the entity class itself now nests in zod shapes and Standard Schema consumers.
  • Update Entity.union(...) to be schema-like on the same terms (slots only) and dispatch through make.
  • Update types/tests/docs and add a changeset documenting the breaking migration.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
README.md Updates top-level docs/examples to use the class directly as a schema.
packages/entity/src/entity.ts Switches from attachInstance to attachSchema during entity construction.
packages/entity/src/instance.ts Implements attachSchema by delegating _zod and ~standard via a per-receiver memoized schema.
packages/entity/src/types.ts Widens Fields to z.core.$ZodType and updates EntityStatic to expose _zod/~standard instead of .instance.
packages/entity/src/shape.ts Updates OnlyNominal/shape() generics to accept z.core.$ZodType field values.
packages/entity/src/computed.ts Updates computed-field typing to accept z.core.$ZodType.
packages/entity/src/union.ts Makes unions schema-like via _zod/~standard slots and routes decoding through make.
packages/entity/src/union.spec.ts Updates union nesting/parsing tests to use Member directly.
packages/entity/src/nesting.spec.ts Updates nesting tests to use entity classes directly as fields.
packages/entity/src/instance.spec.ts Updates schema/Standard Schema behavior tests (but contains a couple of now-stale assertions/descriptions).
packages/entity/src/contract.spec.ts Updates JSON Schema conversion test to reference the class-as-schema surface.
packages/entity/src/shape.test-d.ts Updates type-level assertions to treat entity classes as valid fields.
packages/entity/README.md Partial update toward class-as-schema, but still contains stale .instance references in the updated region.
packages/entity/consumer/index.ts Updates consumer fixture to compose the class directly (external regression guard).
CLAUDE.md Updates architecture notes but still contains one stale instance mention in the edited section.
.changeset/entity-is-a-schema.md Adds release notes + migration guidance for removing .instance.
Suppressed comments (3)

packages/entity/src/instance.spec.ts:51

  • This test still checks for a non-existent "instance" static and doesn't assert anything about the new non-enumerable zod slot properties (_zod, ~standard). Updating it to check the actual non-enumerable surface will make it meaningful again.
test("instance is not enumerable on the class", () => {
  expect(Object.keys(Organization)).not.toContain("instance");
});

packages/entity/src/instance.spec.ts:55

  • expect(Organization).toBe(Organization) is a tautology and no longer tests the intended caching behavior. Since attachSchema memoises the built schema per receiver, assert that the exposed zod slots are stable across reads.
test("instance is built once and reused", () => {
  expect(Organization).toBe(Organization);
});

packages/entity/src/instance.spec.ts:57

  • This test name still refers to the removed .instance surface; the assertion is about the class being a Standard Schema, so the description should match.
test("instance is a Standard Schema, so it is what a framework receives", () => {

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

Comment thread packages/entity/src/union.ts
Comment thread packages/entity/src/instance.spec.ts Outdated
Comment thread packages/entity/src/contract.spec.ts Outdated
Comment thread packages/entity/README.md
Comment thread CLAUDE.md
Comment thread packages/entity/src/shape.test-d.ts Outdated
@btravers
btravers merged commit b9d621e into main Aug 7, 2026
13 checks passed
@btravers
btravers deleted the feat/entity-is-a-schema branch August 7, 2026 01:14
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