Skip to content

Commit 9373afa

Browse files
committed
Add Typed interface and typed factory for discriminated unions
Introduced the Typed<T> interface and typed() factory to support discriminated unions, enabling mutually exclusive state modeling and improved type safety. Updated documentation and examples to illustrate usage and benefits of this pattern.
1 parent a88e382 commit 9373afa

2 files changed

Lines changed: 197 additions & 2 deletions

File tree

.changeset/typed-discriminant.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@evolu/common": minor
3+
---
4+
5+
Added `Typed` interface and `typed` factory for discriminated unions
6+
7+
Discriminated unions model mutually exclusive states where each variant is a distinct type. This makes illegal states unrepresentable — invalid combinations cannot exist.
8+
9+
```ts
10+
// Type-only usage for static discrimination
11+
interface Pending extends Typed<"Pending"> {
12+
readonly createdAt: DateIso;
13+
}
14+
interface Shipped extends Typed<"Shipped"> {
15+
readonly trackingNumber: TrackingNumber;
16+
}
17+
type OrderState = Pending | Shipped;
18+
19+
// Runtime validation with typed() factory
20+
const Pending = typed("Pending", { createdAt: DateIso });
21+
const Shipped = typed("Shipped", { trackingNumber: TrackingNumber });
22+
```

packages/common/src/Type.ts

Lines changed: 175 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)