-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add union(), a union of entities that is itself entity-like #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| --- | ||
| "@btravstack/entity": minor | ||
| --- | ||
|
|
||
| New `Entity.union(discriminant, members)`: a union of entities that is itself | ||
| entity-like. Grouped under `Entity` rather than exported loose — `union` alone | ||
| is too generic a name to take from a consumer's import scope. | ||
|
|
||
| ```ts | ||
| const Member = Entity.union("kind", [User, ServiceAccount]); | ||
|
|
||
| Member.make(row).getOrThrow(); // User | ServiceAccount | ||
| Member.input; // discriminated union, one branch per member | ||
| Member.output; // ditto — JSON Schema in both directions | ||
| Member.instance; // parses to the member class, and nests as a field | ||
| Member.members; // the tuple, for registries and exhaustiveness | ||
| ``` | ||
|
|
||
| Previously a union of entities was a plain zod schema and you had to choose | ||
| which half to lose: `z.discriminatedUnion` over the `output` schemas gave a | ||
| contract but plain data, while `z.union` over the `instance` schemas gave | ||
| instances but no output JSON Schema. Neither had `make`. | ||
|
|
||
| It dispatches on the discriminant rather than trying each branch, so a member | ||
| whose own validation fails reports its own issues rather than every branch's. |
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
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
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { match, P } from "unthrown"; | ||
| import { test } from "vitest"; | ||
| import { z } from "zod"; | ||
|
|
||
| import { Entity } from "./index.js"; | ||
|
|
||
| const UserId = z.uuid().brand("UserId"); | ||
| const Email = z.email().brand("Email"); | ||
| const Label = z.string().min(1).brand("Label"); | ||
|
|
||
| class User extends Entity("User")({ kind: z.literal("user"), id: UserId, email: Email }) {} | ||
| class Svc extends Entity("Svc")({ kind: z.literal("svc"), id: UserId, label: Label }) {} | ||
|
|
||
| const Member = Entity.union("kind", [User, Svc]); | ||
|
|
||
| test("make yields the member union, not unknown", () => { | ||
| const m = Member.make({}).getOrThrow(); | ||
| // reachable only if the union type is precise | ||
| const email: z.infer<typeof Email> | undefined = | ||
| "kind" in m && m.kind === "user" ? m.email : undefined; | ||
| void email; | ||
| // @ts-expect-error `label` is not on the `user` branch | ||
| const wrong = m.kind === "user" ? m.label : undefined; | ||
| void wrong; | ||
| }); | ||
|
|
||
| test("the union is exhaustively matchable on the runtime tag", () => { | ||
| const m = Member.make({}).getOrThrow(); | ||
| const described: string = match(m) | ||
| .with(P.tag("User"), (u) => u.email as string) | ||
| .with(P.tag("Svc"), (s) => s.label as string) | ||
| .exhaustive(); | ||
| void described; | ||
| }); | ||
|
|
||
| test("a union needs at least two members", () => { | ||
| // @ts-expect-error one member is not a union | ||
| Entity.union("kind", [User]); | ||
| }); |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import { Err, type Result } from "unthrown"; | ||
| import { z } from "zod"; | ||
|
|
||
| import { InvalidEntity } from "./errors.js"; | ||
|
|
||
| /** | ||
| * The part of an entity a union needs. Typed loosely — `EntityStatic` is | ||
| * generic in the entity's own shape, and a union has to accept any of them. | ||
| */ | ||
| type UnionMember = { | ||
| readonly entityName: string; | ||
| readonly input: z.ZodObject<z.core.$ZodLooseShape>; | ||
| readonly output: z.ZodObject<z.core.$ZodLooseShape>; | ||
| readonly instance: z.ZodType; | ||
| make(state: unknown): Result<unknown, InvalidEntity>; | ||
| }; | ||
|
|
||
| /** | ||
| * The member's instance type, read off `instance` rather than off `make`. | ||
| * `make` is generic in a `this` parameter, which cannot be inferred through a | ||
| * loosened member type; `instance` states the same type plainly. | ||
| */ | ||
| type InstanceOf<M extends UnionMember> = z.infer<M["instance"]>; | ||
|
|
||
| export type EntityUnion<K extends string, M extends readonly UnionMember[]> = { | ||
| readonly discriminant: K; | ||
| readonly members: M; | ||
| readonly input: z.ZodType<unknown>; | ||
| readonly output: z.ZodType<unknown>; | ||
| readonly instance: z.ZodType<InstanceOf<M[number]>>; | ||
| make(state: unknown): Result<InstanceOf<M[number]>, InvalidEntity>; | ||
| }; | ||
|
|
||
| /** | ||
| * `z.discriminatedUnion` constrains its branches to `$ZodTypeDiscriminable<K>`, | ||
| * which asserts the branch's *input* carries the key. A member's schema is | ||
| * generic here, so TypeScript cannot see that it does — the runtime check in | ||
| * `byValue` below reads the very literal that proves it. Cast rather than | ||
| * fight a constraint the construction already satisfies. | ||
| */ | ||
| type Branches = readonly [z.core.$ZodTypeDiscriminable, ...z.core.$ZodTypeDiscriminable[]]; | ||
|
|
||
| /** | ||
| * A union of entities that is itself usable like one: it validates, it makes | ||
| * the right class, and it hands a contract layer plain schemas. | ||
| * | ||
| * ```ts | ||
| * const Member = union("kind", [User, ServiceAccount]); | ||
| * Member.make(row).getOrThrow(); // User | ServiceAccount | ||
| * ``` | ||
| * | ||
| * `discriminant` names a **declared domain field**, not the entity's `_tag`. | ||
| * The tag is non-enumerable and absent after serialisation, so a union built | ||
| * on it could not survive a JSON round trip. The two mechanisms are not | ||
| * redundant: the field discriminates *data*, the tag matches an *instance* | ||
| * with `P.tag(...)`. | ||
| * | ||
| * `input` and `output` are real discriminated unions, so a contract layer gets | ||
| * one branch per member and JSON Schema in both directions. | ||
| */ | ||
| export function union< | ||
| const K extends string, | ||
| const M extends readonly [UnionMember, UnionMember, ...UnionMember[]], | ||
| >(discriminant: K, members: M): EntityUnion<K, M> { | ||
| const input = z.discriminatedUnion( | ||
| discriminant, | ||
| members.map((m) => m.input) as unknown as Branches, | ||
| ); | ||
| const output = z.discriminatedUnion( | ||
| discriminant, | ||
| members.map((m) => m.output) as unknown as Branches, | ||
| ); | ||
|
|
||
| const byValue = new Map<unknown, UnionMember>( | ||
| members.map((m) => [(m.input.shape[discriminant] as z.ZodLiteral<string>).value, m]), | ||
| ); | ||
|
|
||
| const entity = members.map((m) => m.entityName).join(" | "); | ||
| const known = [...byValue.keys()].map((k) => JSON.stringify(k)).join(", "); | ||
|
|
||
| /** Dispatch on the discriminant rather than trying each branch in turn, so a | ||
| * failing member reports *its* issues instead of every branch's. */ | ||
| const lookup = (state: unknown): UnionMember | undefined => | ||
| byValue.get((state as Record<string, unknown> | null | undefined)?.[discriminant]); | ||
|
|
||
| const unknownDiscriminant = (state: unknown) => ({ | ||
| path: [discriminant] as readonly PropertyKey[], | ||
| message: `Invalid discriminant ${JSON.stringify( | ||
| (state as Record<string, unknown> | null | undefined)?.[discriminant], | ||
| )}; expected one of ${known}`, | ||
| }); | ||
|
|
||
| const make = (state: unknown): Result<InstanceOf<M[number]>, InvalidEntity> => { | ||
| const member = lookup(state); | ||
| return member === undefined | ||
| ? Err(new InvalidEntity({ entity, issues: [unknownDiscriminant(state)] })) | ||
| : (member.make(state) as Result<InstanceOf<M[number]>, InvalidEntity>); | ||
| }; | ||
|
|
||
| const instance = z.unknown().transform((raw, ctx) => { | ||
| const member = lookup(raw); | ||
| if (member === undefined) { | ||
| const issue = unknownDiscriminant(raw); | ||
| ctx.addIssue({ code: "custom", message: issue.message, path: [discriminant] }); | ||
| return z.NEVER; | ||
| } | ||
| const parsed = member.instance.safeParse(raw); | ||
| if (!parsed.success) { | ||
| for (const issue of parsed.error.issues) { | ||
| ctx.addIssue({ code: "custom", message: issue.message, path: [...issue.path] }); | ||
| } | ||
| return z.NEVER; | ||
| } | ||
| return parsed.data as InstanceOf<M[number]>; | ||
| }) as unknown as z.ZodType<InstanceOf<M[number]>>; | ||
|
|
||
| return { discriminant, members, input, output, instance, make }; | ||
| } | ||
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.