@@ -75,8 +75,10 @@ import { IntentionalNever } from "./Types.js";
7575 *
7676 * ## Branded types
7777 *
78- * Branding adds semantic meaning & constraints while preserving the runtime
79- * shape:
78+ * Branding is the recommended way to define types in Evolu. Instead of using
79+ * primitive types like `string` or `number` directly, wrap them with
80+ * {@link brand} to create semantically meaningful types. See {@link Brand} for
81+ * why this matters.
8082 *
8183 * ```ts
8284 * const CurrencyCode = brand("CurrencyCode", String, (value) =>
@@ -854,6 +856,10 @@ export const formatIsTypeError = createTypeErrorFormatter<EvoluTypeError>(
854856/**
855857 * Branded {@link Type}.
856858 *
859+ * Branding is the recommended way to define types in Evolu. Instead of using
860+ * primitive types like `string` or `number` directly, wrap them with `brand` to
861+ * create semantically meaningful types. See {@link Brand} for why this matters.
862+ *
857863 * The `brand` Type Factory takes the name of a new {@link Brand}, a parent Type
858864 * to be branded, and the optional `refine` function for additional constraint.
859865 *
@@ -3213,6 +3219,173 @@ export const formatObjectWithRecordError = <Error extends TypeError>(
32133219 }
32143220 } ) ;
32153221
3222+ /**
3223+ * Base interface for objects with a discriminant `type` property.
3224+ *
3225+ * This enables
3226+ * {@link https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions | discriminated unions}
3227+ * (also known as tagged unions) — a pattern where TypeScript uses a literal
3228+ * `type` field to narrow union types automatically.
3229+ *
3230+ * ## Why Discriminated Unions?
3231+ *
3232+ * Discriminated unions model states that are **mutually exclusive**. Instead of
3233+ * optional fields and boolean flags that can combine into invalid
3234+ * configurations, each variant is a distinct type. This makes illegal states
3235+ * unrepresentable — invalid combinations cannot exist, so bugs cannot create
3236+ * them.
3237+ *
3238+ * Benefits:
3239+ *
3240+ * - **Self-documenting** — Union cases immediately show all possible states
3241+ * - **Compile-time safety** — TypeScript enforces handling all cases
3242+ * - **Refactoring-friendly** — Adding a new state breaks code that doesn't handle
3243+ * it
3244+ *
3245+ * ## Examples
3246+ *
3247+ * ```ts
3248+ * // Bad: optional fields allow invalid states (no contact info at all)
3249+ * interface Contact {
3250+ * readonly email?: Email;
3251+ * readonly phone?: Phone;
3252+ * }
3253+ *
3254+ * // Good: discriminated union makes "at least one" explicit
3255+ * interface EmailOnly extends Typed<"EmailOnly"> {
3256+ * readonly email: Email;
3257+ * }
3258+ * interface PhoneOnly extends Typed<"PhoneOnly"> {
3259+ * readonly phone: Phone;
3260+ * }
3261+ * interface EmailAndPhone extends Typed<"EmailAndPhone"> {
3262+ * readonly email: Email;
3263+ * readonly phone: Phone;
3264+ * }
3265+ *
3266+ * type ContactInfo = EmailOnly | PhoneOnly | EmailAndPhone;
3267+ * ```
3268+ *
3269+ * ```ts
3270+ * interface Pending extends Typed<"Pending"> {
3271+ * readonly createdAt: DateIso;
3272+ * }
3273+ * interface Shipped extends Typed<"Shipped"> {
3274+ * readonly trackingNumber: TrackingNumber;
3275+ * }
3276+ * interface Delivered extends Typed<"Delivered"> {
3277+ * readonly deliveredAt: DateIso;
3278+ * }
3279+ * interface Cancelled extends Typed<"Cancelled"> {
3280+ * readonly reason: CancellationReason;
3281+ * }
3282+ *
3283+ * type OrderState = Pending | Shipped | Delivered | Cancelled;
3284+ *
3285+ * // TypeScript enforces exhaustiveness via return type
3286+ * const getStatusMessage = (state: OrderState): string => {
3287+ * switch (state.type) {
3288+ * case "Pending":
3289+ * return "Order placed";
3290+ * case "Shipped":
3291+ * return `Shipped: ${state.trackingNumber}`;
3292+ * case "Delivered":
3293+ * return `Delivered on ${state.deliveredAt.toLocaleDateString()}`;
3294+ * case "Cancelled":
3295+ * return `Cancelled: ${state.reason}`;
3296+ * }
3297+ * };
3298+ *
3299+ * // For void functions, use exhaustiveCheck to ensure all cases are handled
3300+ * const logState = (state: OrderState): void => {
3301+ * switch (state.type) {
3302+ * case "Pending":
3303+ * console.log("Order placed");
3304+ * break;
3305+ * case "Shipped":
3306+ * console.log(`Shipped: ${state.trackingNumber}`);
3307+ * break;
3308+ * case "Delivered":
3309+ * console.log(
3310+ * `Delivered on ${state.deliveredAt.toLocaleDateString()}`,
3311+ * );
3312+ * break;
3313+ * case "Cancelled":
3314+ * console.log(`Cancelled: ${state.reason}`);
3315+ * break;
3316+ * default:
3317+ * exhaustiveCheck(state);
3318+ * }
3319+ * };
3320+ * ```
3321+ *
3322+ * ## Why `type` (and not e.g. `_tag`)?
3323+ *
3324+ * Underscore-prefixing is meant to avoid clashing with domain properties, but
3325+ * proper discriminated union design means the discriminant IS the domain
3326+ * concept — there's no clash to avoid. The `type` prop name also aligns with
3327+ * {@link Type}'s name. If an entity has a meaningful "type" (like product
3328+ * category), model it as the discriminant itself:
3329+ *
3330+ * ```ts
3331+ * interface Electronics extends Typed<"Electronics"> {
3332+ * voltage: Voltage;
3333+ * }
3334+ * interface Clothing extends Typed<"Clothing"> {
3335+ * size: Size;
3336+ * }
3337+ * type Product = Electronics | Clothing;
3338+ * ```
3339+ *
3340+ * @see {@link exhaustiveCheck } to ensure all cases are handled in void functions.
3341+ * @see {@link typed } for runtime-validated typed objects.
3342+ */
3343+ export interface Typed < T extends string > {
3344+ readonly type : T ;
3345+ }
3346+
3347+ /**
3348+ * Creates a runtime-validated typed object with a `type` discriminant.
3349+ *
3350+ * ## Example
3351+ *
3352+ * ```ts
3353+ * const Card = typed("Card", {
3354+ * cardNumber: CardNumber,
3355+ * expiry: DateIso,
3356+ * });
3357+ *
3358+ * const Cash = typed("Cash", {
3359+ * currency: NonEmptyTrimmedString,
3360+ * });
3361+ *
3362+ * const Payment = union(Card, Cash);
3363+ * type Payment = typeof Payment.Type;
3364+ *
3365+ * const result = Payment.fromUnknown(data);
3366+ * if (result.ok) {
3367+ * switch (result.value.type) {
3368+ * case "Card":
3369+ * console.log(result.value.cardNumber);
3370+ * break;
3371+ * case "Cash":
3372+ * console.log(result.value.currency);
3373+ * break;
3374+ * }
3375+ * }
3376+ * ```
3377+ *
3378+ * @see {@link Typed } for type-only discrimination.
3379+ */
3380+ export const typed = <
3381+ Tag extends string ,
3382+ Props extends Record < string , AnyType > ,
3383+ > (
3384+ tag : Tag ,
3385+ props : Props ,
3386+ ) : ObjectType < { type : LiteralType < Tag > } & Props > =>
3387+ object ( { type : literal ( tag ) , ...props } ) ;
3388+
32163389/**
32173390 * Union {@link Type}.
32183391 *
0 commit comments