feat!: make the entity class itself a zod schema, removing instance - #19
Merged
Conversation
There was a problem hiding this comment.
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
.instanceas 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 throughmake. - 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. SinceattachSchemamemoises 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
.instancesurface; 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Your idea, and it is better than either alternative I proposed.
.instanceisgone; the class is the schema.
z.object({ owner: Organization })never worked before — the README had aparagraph explaining why it could not. That paragraph is gone.
Slots, not methods
The class carries
_zodand~standardand nothing else of zod's. Verifiedthat the minimal pair is sufficient for zod's shape constraint at both the
runtime and type level, which matters: the full
ZodTypesurface would put athrowing
.parse()on every entity, beside themakethat returns aResult. That is the thing this package exists to avoid.So
Organization.parse(raw)does not exist — usemake— and wrapping goesthrough zod's function forms. All verified working:
~standardreturns, reversing #16I 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
Fieldswidens fromz.ZodTypeAnytoz.core.$ZodType, since an entityclass 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 nestsidentically — and its internal dispatch now goes through
makerather thana parse method, because members are classes now.
attachInstance→attachSchema, memoising per receiver in aWeakMapsoa schema built from a subclass still yields that subclass.
The consumer fixture earned its keep
It failed on
Organization.instanceand had to be updated — exactly theregression 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.