Skip to content

Union members are second-class: no construct signature, no instance type, no shared behaviour #45

Description

@btravers

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions