Summary
A member of an Entity.union(...) is second-class compared to a plain entity. Three symptoms, one theme: a union is a value with a make, whereas an entity is a class with a type, a construct signature and a place to hang behaviour. Modelling a domain type as a union therefore costs you the ergonomics that make the entity API pleasant.
Versions: @btravstack/entity@0.3.0, zod@4.3.6, typescript@5.9.3, --strict --module nodenext.
Symptom 1 — a union cannot be declared with the class idiom entities use
import { Entity } from "@btravstack/entity";
import { z } from "zod";
const Id = z.string().brand("Id");
const Label = z.string().brand("Label");
const Base = Entity("Account")({ id: Id, label: Label });
class Personal extends Base.extend("Personal")({ kind: z.literal("personal") }) {}
class Business extends Base.extend("Business")({ kind: z.literal("business") }) {}
class Account extends Entity.union("kind", [Personal, Business]) {}
error TS2507: Type 'EntityUnion<"kind", readonly [typeof Personal, typeof Business]>'
is not a constructor function type.
Entity(tag)(fields) returns an EntityStatic carrying new (d: Sealed<OutputOf<S, A>>) => ConstructedInstance<…>, which is what makes class X extends Entity("X")({…}) {} — the idiom the README leads with — work. Entity.union(...) returns an EntityUnion, which has discriminant / members / input / output / make plus the two zod slots, and no construct signature. So the two ways of declaring a domain type read differently, for a reason that is invisible from the outside.
Symptom 2 — a union has no instance type
Falling back to const leaves nothing to annotate with:
const AccountUnion = Entity.union("kind", [Personal, Business]);
declare const account: AccountUnion;
error TS2749: 'AccountUnion' refers to a value, but is being used as a type here.
Entities carry phantom shape carriers (__input, __output, __createInput, __patch) that consumers can read shapes off. EntityUnion exposes none, so there is no Entity.Instance<typeof AccountUnion> to reach for either. The only spelling available is to enumerate the members by hand:
type AccountInstance =
| InstanceType<typeof Personal>
| InstanceType<typeof Business>;
which has to be maintained in lockstep with the Entity.union(...) call, in a second place, with nothing checking the two agree. Adding a third variant and forgetting this alias compiles fine.
Symptom 3 — .extend() does not carry class-body members through
This is the one that actually changes how you model, rather than how you spell it.
class Base extends Entity("Base")({ id: Id, tags: z.array(Tag) }) {
get isArchived(): boolean {
return this.tags.includes("ARCHIVED");
}
}
class Personal extends Base.extend("Personal")({ kind: z.literal("personal") }) {}
declare const personal: InstanceType<typeof Personal>;
const archived: boolean = personal.isArchived;
error TS2339: Property 'isArchived' does not exist on type 'Personal'.
Consistent with the documented semantics — "It is a fresh entity, not a subclass" — so this may be working as designed. But the consequence is that shared behaviour across the variants of a union has nowhere to live. Behaviour common to every variant must be written into each variant's class body, duplicated once per member.
Why this matters
We hit all three adopting the library for an aggregate that is naturally a discriminated union: a shared base, two variants, a union over the discriminant. That is a common shape — order/subscription/account kinds, anything with a line-of-business split — and it is exactly the shape Entity.union exists to serve.
The concrete bite was symptom 3. We have a rule that maps a state transition to two derived numeric fields — genuinely entity behaviour, and precisely what you would want as a method. With no base to hang it on, the options were to write the same method body into both variants (duplicating the rule we had just extracted in order to stop duplicating it) or to leave it as a free function called from the persistence adapter. We left it as a free function, and a reviewer immediately and correctly asked why the rule was not carried by the entity. The honest answer was "because the library gives it nowhere to live", which is not a satisfying thing to write in a code review.
Symptoms 1 and 2 are milder — a const and a hand-maintained alias work — but they make a union read as a lesser construct at the declaration site, which nudges people away from modelling with it.
Suggestions
1 and 2 look like they share a fix: giving EntityUnion a construct signature (parsing to the member instance, as the members' own _zod slot already does) would make class Account extends Entity.union(...) {} work and make Account usable as a type, collapsing the hand-maintained alias. Failing that, a phantom __instance carrier plus an Entity.Instance<T> helper would fix 2 alone.
3 is a deeper design question and I would understand leaving it: an extend-provided mixin, or an explicit behaviour option on the base that variants inherit, would both work, but either is a real change to what "fresh entity, not a subclass" means. Even documenting the constraint explicitly under extend() would help — the current wording explains the identity semantics but not that it costs you shared methods.
Happy to test a candidate build against the real shape.
Summary
A member of an
Entity.union(...)is second-class compared to a plain entity. Three symptoms, one theme: a union is a value with amake, whereas an entity is a class with a type, a construct signature and a place to hang behaviour. Modelling a domain type as a union therefore costs you the ergonomics that make the entity API pleasant.Versions:
@btravstack/entity@0.3.0,zod@4.3.6,typescript@5.9.3,--strict --module nodenext.Symptom 1 — a union cannot be declared with the class idiom entities use
Entity(tag)(fields)returns anEntityStaticcarryingnew (d: Sealed<OutputOf<S, A>>) => ConstructedInstance<…>, which is what makesclass X extends Entity("X")({…}) {}— the idiom the README leads with — work.Entity.union(...)returns anEntityUnion, which hasdiscriminant/members/input/output/makeplus the two zod slots, and no construct signature. So the two ways of declaring a domain type read differently, for a reason that is invisible from the outside.Symptom 2 — a union has no instance type
Falling back to
constleaves nothing to annotate with:Entities carry phantom shape carriers (
__input,__output,__createInput,__patch) that consumers can read shapes off.EntityUnionexposes none, so there is noEntity.Instance<typeof AccountUnion>to reach for either. The only spelling available is to enumerate the members by hand:which has to be maintained in lockstep with the
Entity.union(...)call, in a second place, with nothing checking the two agree. Adding a third variant and forgetting this alias compiles fine.Symptom 3 —
.extend()does not carry class-body members throughThis is the one that actually changes how you model, rather than how you spell it.
Consistent with the documented semantics — "It is a fresh entity, not a subclass" — so this may be working as designed. But the consequence is that shared behaviour across the variants of a union has nowhere to live. Behaviour common to every variant must be written into each variant's class body, duplicated once per member.
Why this matters
We hit all three adopting the library for an aggregate that is naturally a discriminated union: a shared base, two variants, a union over the discriminant. That is a common shape — order/subscription/account kinds, anything with a line-of-business split — and it is exactly the shape
Entity.unionexists to serve.The concrete bite was symptom 3. We have a rule that maps a state transition to two derived numeric fields — genuinely entity behaviour, and precisely what you would want as a method. With no base to hang it on, the options were to write the same method body into both variants (duplicating the rule we had just extracted in order to stop duplicating it) or to leave it as a free function called from the persistence adapter. We left it as a free function, and a reviewer immediately and correctly asked why the rule was not carried by the entity. The honest answer was "because the library gives it nowhere to live", which is not a satisfying thing to write in a code review.
Symptoms 1 and 2 are milder — a
constand a hand-maintained alias work — but they make a union read as a lesser construct at the declaration site, which nudges people away from modelling with it.Suggestions
1 and 2 look like they share a fix: giving
EntityUniona construct signature (parsing to the member instance, as the members' own_zodslot already does) would makeclass Account extends Entity.union(...) {}work and makeAccountusable as a type, collapsing the hand-maintained alias. Failing that, a phantom__instancecarrier plus anEntity.Instance<T>helper would fix 2 alone.3 is a deeper design question and I would understand leaving it: an
extend-provided mixin, or an explicitbehaviouroption on the base that variants inherit, would both work, but either is a real change to what "fresh entity, not a subclass" means. Even documenting the constraint explicitly underextend()would help — the current wording explains the identity semantics but not that it costs you shared methods.Happy to test a candidate build against the real shape.