@loro-extended/change 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -176,6 +176,76 @@ console.log(result); // Updated document state
176
176
 
177
177
  ## Advanced Usage
178
178
 
179
+ ### Discriminated Unions
180
+
181
+ For type-safe tagged unions (like different message types or presence states), use `Shape.plain.discriminatedUnion()`:
182
+
183
+ ```typescript
184
+ import { Shape, mergeValue } from "@loro-extended/change";
185
+
186
+ // Define variant shapes - each must have the discriminant key
187
+ const ClientPresenceShape = Shape.plain.object({
188
+ type: Shape.plain.string("client"), // Literal type for discrimination
189
+ name: Shape.plain.string(),
190
+ input: Shape.plain.object({
191
+ force: Shape.plain.number(),
192
+ angle: Shape.plain.number(),
193
+ }),
194
+ });
195
+
196
+ const ServerPresenceShape = Shape.plain.object({
197
+ type: Shape.plain.string("server"), // Literal type for discrimination
198
+ cars: Shape.plain.record(
199
+ Shape.plain.object({
200
+ x: Shape.plain.number(),
201
+ y: Shape.plain.number(),
202
+ })
203
+ ),
204
+ tick: Shape.plain.number(),
205
+ });
206
+
207
+ // Create the discriminated union
208
+ const GamePresenceSchema = Shape.plain.discriminatedUnion("type", {
209
+ client: ClientPresenceShape,
210
+ server: ServerPresenceShape,
211
+ });
212
+
213
+ // Empty states for each variant
214
+ const EmptyClientPresence = {
215
+ type: "client" as const,
216
+ name: "",
217
+ input: { force: 0, angle: 0 },
218
+ };
219
+
220
+ const EmptyServerPresence = {
221
+ type: "server" as const,
222
+ cars: {},
223
+ tick: 0,
224
+ };
225
+
226
+ // Use with mergeValue for presence data
227
+ const crdtValue = { type: "client", name: "Alice" };
228
+ const result = mergeValue(GamePresenceSchema, crdtValue, EmptyClientPresence);
229
+ // Result: { type: "client", name: "Alice", input: { force: 0, angle: 0 } }
230
+
231
+ // Type-safe filtering
232
+ function handlePresence(presence: typeof result) {
233
+ if (presence.type === "server") {
234
+ // TypeScript knows this is ServerPresence
235
+ console.log(presence.cars, presence.tick);
236
+ } else {
237
+ // TypeScript knows this is ClientPresence
238
+ console.log(presence.name, presence.input);
239
+ }
240
+ }
241
+ ```
242
+
243
+ **Key features:**
244
+ - The discriminant key (e.g., `"type"`) determines which variant shape to use
245
+ - Missing fields are filled from the empty state of the matching variant
246
+ - Works seamlessly with `@loro-extended/react`'s `usePresence` hook
247
+ - Full TypeScript support for discriminated union types
248
+
179
249
  ### Nested Structures
180
250
 
181
251
  Handle complex nested documents with ease:
@@ -335,6 +405,7 @@ const schema = Shape.doc({
335
405
  - `Shape.plain.record(valueShape)` - Object values with dynamic string keys
336
406
  - `Shape.plain.array(itemShape)` - Array values
337
407
  - `Shape.plain.union(shapes)` - Union of value types (e.g., `string | null`)
408
+ - `Shape.plain.discriminatedUnion(key, variants)` - Tagged union types with a discriminant key
338
409
 
339
410
  ### TypedDoc Methods
340
411
 
package/dist/index.d.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  import { LoroList, LoroMovableList, Container, LoroMap, Value, LoroText, LoroCounter, LoroTree, LoroDoc } from 'loro-crdt';
2
2
 
3
- type InferPlainType<T> = T extends Shape<infer P, any> ? P : never;
4
- type InferDraftType<T> = T extends Shape<any, infer D> ? D : never;
3
+ type InferPlainType<T> = T extends Shape<infer P, any, any> ? P : never;
4
+ type InferDraftType<T> = T extends Shape<any, infer D, any> ? D : never;
5
+ /**
6
+ * Extracts the valid empty state type from a shape.
7
+ *
8
+ * For dynamic containers (list, record, etc.), this will be constrained to
9
+ * empty values ([] or {}) to prevent users from expecting per-entry merging.
10
+ */
11
+ type InferEmptyStateType<T> = T extends Shape<any, any, infer E> ? E : never;
5
12
  type Draft<T extends DocShape<Record<string, ContainerShape>>> = InferDraftType<T>;
6
13
 
7
14
  type DraftNodeParams<Shape extends DocShape | ContainerShape> = {
@@ -128,25 +135,27 @@ interface DocShape<NestedShapes extends Record<string, ContainerShape> = Record<
128
135
  [K in keyof NestedShapes]: NestedShapes[K]["_plain"];
129
136
  }, {
130
137
  [K in keyof NestedShapes]: NestedShapes[K]["_draft"];
138
+ }, {
139
+ [K in keyof NestedShapes]: NestedShapes[K]["_emptyState"];
131
140
  }> {
132
141
  readonly _type: "doc";
133
142
  readonly shapes: NestedShapes;
134
143
  }
135
- interface TextContainerShape extends Shape<string, TextDraftNode> {
144
+ interface TextContainerShape extends Shape<string, TextDraftNode, string> {
136
145
  readonly _type: "text";
137
146
  }
138
- interface CounterContainerShape extends Shape<number, CounterDraftNode> {
147
+ interface CounterContainerShape extends Shape<number, CounterDraftNode, number> {
139
148
  readonly _type: "counter";
140
149
  }
141
- interface TreeContainerShape<NestedShape = ContainerOrValueShape> extends Shape<any, any> {
150
+ interface TreeContainerShape<NestedShape = ContainerOrValueShape> extends Shape<any, any, never[]> {
142
151
  readonly _type: "tree";
143
152
  readonly shape: NestedShape;
144
153
  }
145
- interface ListContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<NestedShape["_plain"][], ListDraftNode<NestedShape>> {
154
+ interface ListContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<NestedShape["_plain"][], ListDraftNode<NestedShape>, never[]> {
146
155
  readonly _type: "list";
147
156
  readonly shape: NestedShape;
148
157
  }
149
- interface MovableListContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<NestedShape["_plain"][], MovableListDraftNode<NestedShape>> {
158
+ interface MovableListContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<NestedShape["_plain"][], MovableListDraftNode<NestedShape>, never[]> {
150
159
  readonly _type: "movableList";
151
160
  readonly shape: NestedShape;
152
161
  }
@@ -154,38 +163,40 @@ interface MapContainerShape<NestedShapes extends Record<string, ContainerOrValue
154
163
  [K in keyof NestedShapes]: NestedShapes[K]["_plain"];
155
164
  }, MapDraftNode<NestedShapes> & {
156
165
  [K in keyof NestedShapes]: NestedShapes[K]["_draft"];
166
+ }, {
167
+ [K in keyof NestedShapes]: NestedShapes[K]["_emptyState"];
157
168
  }> {
158
169
  readonly _type: "map";
159
170
  readonly shapes: NestedShapes;
160
171
  }
161
- interface RecordContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<Record<string, NestedShape["_plain"]>, RecordDraftNode<NestedShape>> {
172
+ interface RecordContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<Record<string, NestedShape["_plain"]>, RecordDraftNode<NestedShape>, Record<string, never>> {
162
173
  readonly _type: "record";
163
174
  readonly shape: NestedShape;
164
175
  }
165
176
  type ContainerShape = CounterContainerShape | ListContainerShape | MapContainerShape | MovableListContainerShape | RecordContainerShape | TextContainerShape | TreeContainerShape;
166
177
  type ContainerType = ContainerShape["_type"];
167
- interface StringValueShape<T extends string = string> extends Shape<T, T> {
178
+ interface StringValueShape<T extends string = string> extends Shape<T, T, T> {
168
179
  readonly _type: "value";
169
180
  readonly valueType: "string";
170
181
  readonly options?: T[];
171
182
  }
172
- interface NumberValueShape extends Shape<number, number> {
183
+ interface NumberValueShape extends Shape<number, number, number> {
173
184
  readonly _type: "value";
174
185
  readonly valueType: "number";
175
186
  }
176
- interface BooleanValueShape extends Shape<boolean, boolean> {
187
+ interface BooleanValueShape extends Shape<boolean, boolean, boolean> {
177
188
  readonly _type: "value";
178
189
  readonly valueType: "boolean";
179
190
  }
180
- interface NullValueShape extends Shape<null, null> {
191
+ interface NullValueShape extends Shape<null, null, null> {
181
192
  readonly _type: "value";
182
193
  readonly valueType: "null";
183
194
  }
184
- interface UndefinedValueShape extends Shape<undefined, undefined> {
195
+ interface UndefinedValueShape extends Shape<undefined, undefined, undefined> {
185
196
  readonly _type: "value";
186
197
  readonly valueType: "undefined";
187
198
  }
188
- interface Uint8ArrayValueShape extends Shape<Uint8Array, Uint8Array> {
199
+ interface Uint8ArrayValueShape extends Shape<Uint8Array, Uint8Array, Uint8Array> {
189
200
  readonly _type: "value";
190
201
  readonly valueType: "uint8array";
191
202
  }
@@ -193,32 +204,54 @@ interface ObjectValueShape<T extends Record<string, ValueShape> = Record<string,
193
204
  [K in keyof T]: T[K]["_plain"];
194
205
  }, {
195
206
  [K in keyof T]: T[K]["_draft"];
207
+ }, {
208
+ [K in keyof T]: T[K]["_emptyState"];
196
209
  }> {
197
210
  readonly _type: "value";
198
211
  readonly valueType: "object";
199
212
  readonly shape: T;
200
213
  }
201
- interface RecordValueShape<T extends ValueShape = ValueShape> extends Shape<Record<string, T["_plain"]>, Record<string, T["_draft"]>> {
214
+ interface RecordValueShape<T extends ValueShape = ValueShape> extends Shape<Record<string, T["_plain"]>, Record<string, T["_draft"]>, Record<string, never>> {
202
215
  readonly _type: "value";
203
216
  readonly valueType: "record";
204
217
  readonly shape: T;
205
218
  }
206
- interface ArrayValueShape<T extends ValueShape = ValueShape> extends Shape<T["_plain"][], T["_draft"][]> {
219
+ interface ArrayValueShape<T extends ValueShape = ValueShape> extends Shape<T["_plain"][], T["_draft"][], never[]> {
207
220
  readonly _type: "value";
208
221
  readonly valueType: "array";
209
222
  readonly shape: T;
210
223
  }
211
- interface UnionValueShape<T extends ValueShape[] = ValueShape[]> extends Shape<T[number]["_plain"], T[number]["_draft"]> {
224
+ interface UnionValueShape<T extends ValueShape[] = ValueShape[]> extends Shape<T[number]["_plain"], T[number]["_draft"], T[number]["_emptyState"]> {
212
225
  readonly _type: "value";
213
226
  readonly valueType: "union";
214
227
  readonly shapes: T;
215
228
  }
216
- type ValueShape = StringValueShape | NumberValueShape | BooleanValueShape | NullValueShape | UndefinedValueShape | Uint8ArrayValueShape | ObjectValueShape | RecordValueShape | ArrayValueShape | UnionValueShape;
229
+ /**
230
+ * A discriminated union shape that uses a discriminant key to determine which variant to use.
231
+ * This enables type-safe handling of tagged unions like:
232
+ *
233
+ * ```typescript
234
+ * type GamePresence =
235
+ * | { type: "client"; name: string; input: { force: number; angle: number } }
236
+ * | { type: "server"; cars: Record<string, CarState>; tick: number }
237
+ * ```
238
+ *
239
+ * @typeParam K - The discriminant key (e.g., "type")
240
+ * @typeParam T - A record mapping discriminant values to their object shapes
241
+ */
242
+ interface DiscriminatedUnionValueShape<K extends string = string, T extends Record<string, ObjectValueShape> = Record<string, ObjectValueShape>> extends Shape<T[keyof T]["_plain"], T[keyof T]["_draft"], T[keyof T]["_emptyState"]> {
243
+ readonly _type: "value";
244
+ readonly valueType: "discriminatedUnion";
245
+ readonly discriminantKey: K;
246
+ readonly variants: T;
247
+ }
248
+ type ValueShape = StringValueShape | NumberValueShape | BooleanValueShape | NullValueShape | UndefinedValueShape | Uint8ArrayValueShape | ObjectValueShape | RecordValueShape | ArrayValueShape | UnionValueShape | DiscriminatedUnionValueShape;
217
249
  type ContainerOrValueShape = ContainerShape | ValueShape;
218
- interface Shape<Plain, Draft> {
250
+ interface Shape<Plain, Draft, EmptyState = Plain> {
219
251
  readonly _type: string;
220
252
  readonly _plain: Plain;
221
253
  readonly _draft: Draft;
254
+ readonly _emptyState: EmptyState;
222
255
  }
223
256
  /**
224
257
  * The LoroShape factory object
@@ -247,6 +280,33 @@ declare const Shape: {
247
280
  record: <T extends ValueShape>(shape: T) => RecordValueShape<T>;
248
281
  array: <T extends ValueShape>(shape: T) => ArrayValueShape<T>;
249
282
  union: <T extends ValueShape[]>(shapes: T) => UnionValueShape<T>;
283
+ /**
284
+ * Creates a discriminated union shape for type-safe tagged unions.
285
+ *
286
+ * @example
287
+ * ```typescript
288
+ * const ClientPresenceShape = Shape.plain.object({
289
+ * type: Shape.plain.string("client"),
290
+ * name: Shape.plain.string(),
291
+ * input: Shape.plain.object({ force: Shape.plain.number(), angle: Shape.plain.number() }),
292
+ * })
293
+ *
294
+ * const ServerPresenceShape = Shape.plain.object({
295
+ * type: Shape.plain.string("server"),
296
+ * cars: Shape.plain.record(Shape.plain.object({ x: Shape.plain.number(), y: Shape.plain.number() })),
297
+ * tick: Shape.plain.number(),
298
+ * })
299
+ *
300
+ * const GamePresenceSchema = Shape.plain.discriminatedUnion("type", {
301
+ * client: ClientPresenceShape,
302
+ * server: ServerPresenceShape,
303
+ * })
304
+ * ```
305
+ *
306
+ * @param discriminantKey - The key used to discriminate between variants (e.g., "type")
307
+ * @param variants - A record mapping discriminant values to their object shapes
308
+ */
309
+ discriminatedUnion: <K extends string, T extends Record<string, ObjectValueShape>>(discriminantKey: K, variants: T) => DiscriminatedUnionValueShape<K, T>;
250
310
  };
251
311
  };
252
312
  type ShapeToContainer<T extends DocShape | ContainerShape> = T extends TextContainerShape ? LoroText : T extends CounterContainerShape ? LoroCounter : T extends ListContainerShape ? LoroList : T extends MovableListContainerShape ? LoroMovableList : T extends MapContainerShape | RecordContainerShape ? LoroMap : T extends TreeContainerShape ? LoroTree : never;
@@ -291,7 +351,16 @@ declare class TypedDoc<Shape extends DocShape> {
291
351
  private shape;
292
352
  private emptyState;
293
353
  private doc;
294
- constructor(shape: Shape, emptyState: InferPlainType<Shape>, doc?: LoroDoc);
354
+ /**
355
+ * Creates a new TypedDoc with the given schema and empty state.
356
+ *
357
+ * @param shape - The document schema
358
+ * @param emptyState - Default values for the document. For dynamic containers
359
+ * (list, record, etc.), only empty values ([] or {}) are allowed. Use
360
+ * `.change()` to add initial data after construction.
361
+ * @param doc - Optional existing LoroDoc to wrap
362
+ */
363
+ constructor(shape: Shape, emptyState: InferEmptyStateType<Shape>, doc?: LoroDoc);
295
364
  get value(): InferPlainType<Shape>;
296
365
  change(fn: (draft: Draft<Shape>) => void): InferPlainType<Shape>;
297
366
  /**
@@ -314,7 +383,7 @@ declare class TypedDoc<Shape extends DocShape> {
314
383
  get docShape(): Shape;
315
384
  get rawValue(): any;
316
385
  }
317
- declare function createTypedDoc<Shape extends DocShape>(shape: Shape, emptyState: InferPlainType<Shape>, existingDoc?: LoroDoc): TypedDoc<Shape>;
386
+ declare function createTypedDoc<Shape extends DocShape>(shape: Shape, emptyState: InferEmptyStateType<Shape>, existingDoc?: LoroDoc): TypedDoc<Shape>;
318
387
 
319
388
  /**
320
389
  * Overlays CRDT state with empty state defaults
@@ -337,4 +406,4 @@ declare function mergeValue<Shape extends ContainerShape | ValueShape>(shape: Sh
337
406
  */
338
407
  declare function validateEmptyState<T extends DocShape>(emptyState: unknown, schema: T): InferPlainType<T>;
339
408
 
340
- export { type ArrayValueShape, type ContainerOrValueShape, type ContainerShape, type CounterContainerShape, type DocShape, type Draft, type InferDraftType, type InferPlainType, type ListContainerShape, type MapContainerShape, type MovableListContainerShape, type RecordContainerShape, type RecordValueShape, type ContainerType as RootContainerType, Shape, type TextContainerShape, type TreeContainerShape, TypedDoc, type ValueShape, createTypedDoc, mergeValue, overlayEmptyState, validateEmptyState };
409
+ export { type ArrayValueShape, type ContainerOrValueShape, type ContainerShape, type CounterContainerShape, type DiscriminatedUnionValueShape, type DocShape, type Draft, type InferDraftType, type InferEmptyStateType, type InferPlainType, type ListContainerShape, type MapContainerShape, type MovableListContainerShape, type ObjectValueShape, type RecordContainerShape, type RecordValueShape, type ContainerType as RootContainerType, Shape, type TextContainerShape, type TreeContainerShape, TypedDoc, type UnionValueShape, type ValueShape, createTypedDoc, mergeValue, overlayEmptyState, validateEmptyState };
package/dist/index.js CHANGED
@@ -487,13 +487,13 @@ var MapDraftNode = class extends DraftNode {
487
487
  node = containerValue;
488
488
  } else {
489
489
  const emptyState = this.emptyState?.[key];
490
- if (!emptyState) {
490
+ if (emptyState === void 0) {
491
491
  throw new Error("empty state required");
492
492
  }
493
493
  node = emptyState;
494
494
  }
495
495
  }
496
- if (!node) throw new Error("no container made");
496
+ if (node === void 0) throw new Error("no container made");
497
497
  this.propertyCache.set(key, node);
498
498
  }
499
499
  return node;
@@ -505,6 +505,7 @@ var MapDraftNode = class extends DraftNode {
505
505
  get: () => this.getOrCreateNode(key, shape),
506
506
  set: isValueShape(shape) ? (value) => {
507
507
  this.container.set(key, value);
508
+ this.propertyCache.set(key, value);
508
509
  } : void 0
509
510
  });
510
511
  }
@@ -1105,9 +1106,29 @@ function mergeValue(shape, crdtValue, emptyValue) {
1105
1106
  }
1106
1107
  return result;
1107
1108
  }
1109
+ if (shape._type === "value" && shape.valueType === "discriminatedUnion") {
1110
+ return mergeDiscriminatedUnion(
1111
+ shape,
1112
+ crdtValue,
1113
+ emptyValue
1114
+ );
1115
+ }
1108
1116
  return crdtValue ?? emptyValue;
1109
1117
  }
1110
1118
  }
1119
+ function mergeDiscriminatedUnion(shape, crdtValue, emptyValue) {
1120
+ const crdtObj = crdtValue ?? {};
1121
+ const emptyObj = emptyValue ?? {};
1122
+ const discriminantValue = crdtObj[shape.discriminantKey] ?? emptyObj[shape.discriminantKey];
1123
+ if (typeof discriminantValue !== "string") {
1124
+ return emptyValue;
1125
+ }
1126
+ const variantShape = shape.variants[discriminantValue];
1127
+ if (!variantShape) {
1128
+ return crdtValue ?? emptyValue;
1129
+ }
1130
+ return mergeValue(variantShape, crdtValue, emptyValue);
1131
+ }
1111
1132
 
1112
1133
  // src/validation.ts
1113
1134
  function validateValue(value, schema, path = "") {
@@ -1309,6 +1330,15 @@ function validateEmptyState(emptyState, schema) {
1309
1330
 
1310
1331
  // src/change.ts
1311
1332
  var TypedDoc = class {
1333
+ /**
1334
+ * Creates a new TypedDoc with the given schema and empty state.
1335
+ *
1336
+ * @param shape - The document schema
1337
+ * @param emptyState - Default values for the document. For dynamic containers
1338
+ * (list, record, etc.), only empty values ([] or {}) are allowed. Use
1339
+ * `.change()` to add initial data after construction.
1340
+ * @param doc - Optional existing LoroDoc to wrap
1341
+ */
1312
1342
  constructor(shape, emptyState, doc = new LoroDoc()) {
1313
1343
  this.shape = shape;
1314
1344
  this.emptyState = emptyState;
@@ -1382,49 +1412,57 @@ var Shape = {
1382
1412
  _type: "doc",
1383
1413
  shapes: shape,
1384
1414
  _plain: {},
1385
- _draft: {}
1415
+ _draft: {},
1416
+ _emptyState: {}
1386
1417
  }),
1387
1418
  // CRDTs are represented by Loro Containers--they converge on state using Loro's
1388
1419
  // various CRDT algorithms
1389
1420
  counter: () => ({
1390
1421
  _type: "counter",
1391
1422
  _plain: 0,
1392
- _draft: {}
1423
+ _draft: {},
1424
+ _emptyState: 0
1393
1425
  }),
1394
1426
  list: (shape) => ({
1395
1427
  _type: "list",
1396
1428
  shape,
1397
1429
  _plain: [],
1398
- _draft: {}
1430
+ _draft: {},
1431
+ _emptyState: []
1399
1432
  }),
1400
1433
  map: (shape) => ({
1401
1434
  _type: "map",
1402
1435
  shapes: shape,
1403
1436
  _plain: {},
1404
- _draft: {}
1437
+ _draft: {},
1438
+ _emptyState: {}
1405
1439
  }),
1406
1440
  record: (shape) => ({
1407
1441
  _type: "record",
1408
1442
  shape,
1409
1443
  _plain: {},
1410
- _draft: {}
1444
+ _draft: {},
1445
+ _emptyState: {}
1411
1446
  }),
1412
1447
  movableList: (shape) => ({
1413
1448
  _type: "movableList",
1414
1449
  shape,
1415
1450
  _plain: [],
1416
- _draft: {}
1451
+ _draft: {},
1452
+ _emptyState: []
1417
1453
  }),
1418
1454
  text: () => ({
1419
1455
  _type: "text",
1420
1456
  _plain: "",
1421
- _draft: {}
1457
+ _draft: {},
1458
+ _emptyState: ""
1422
1459
  }),
1423
1460
  tree: (shape) => ({
1424
1461
  _type: "tree",
1425
1462
  shape,
1426
1463
  _plain: {},
1427
- _draft: {}
1464
+ _draft: {},
1465
+ _emptyState: []
1428
1466
  }),
1429
1467
  // Values are represented as plain JS objects, with the limitation that they MUST be
1430
1468
  // representable as a Loro "Value"--basically JSON. The behavior of a Value is basically
@@ -1436,58 +1474,67 @@ var Shape = {
1436
1474
  valueType: "string",
1437
1475
  _plain: options[0] ?? "",
1438
1476
  _draft: options[0] ?? "",
1477
+ _emptyState: options[0] ?? "",
1439
1478
  options: options.length > 0 ? options : void 0
1440
1479
  }),
1441
1480
  number: () => ({
1442
1481
  _type: "value",
1443
1482
  valueType: "number",
1444
1483
  _plain: 0,
1445
- _draft: 0
1484
+ _draft: 0,
1485
+ _emptyState: 0
1446
1486
  }),
1447
1487
  boolean: () => ({
1448
1488
  _type: "value",
1449
1489
  valueType: "boolean",
1450
1490
  _plain: false,
1451
- _draft: false
1491
+ _draft: false,
1492
+ _emptyState: false
1452
1493
  }),
1453
1494
  null: () => ({
1454
1495
  _type: "value",
1455
1496
  valueType: "null",
1456
1497
  _plain: null,
1457
- _draft: null
1498
+ _draft: null,
1499
+ _emptyState: null
1458
1500
  }),
1459
1501
  undefined: () => ({
1460
1502
  _type: "value",
1461
1503
  valueType: "undefined",
1462
1504
  _plain: void 0,
1463
- _draft: void 0
1505
+ _draft: void 0,
1506
+ _emptyState: void 0
1464
1507
  }),
1465
1508
  uint8Array: () => ({
1466
1509
  _type: "value",
1467
1510
  valueType: "uint8array",
1468
1511
  _plain: new Uint8Array(),
1469
- _draft: new Uint8Array()
1512
+ _draft: new Uint8Array(),
1513
+ _emptyState: new Uint8Array()
1470
1514
  }),
1471
1515
  object: (shape) => ({
1472
1516
  _type: "value",
1473
1517
  valueType: "object",
1474
1518
  shape,
1475
1519
  _plain: {},
1476
- _draft: {}
1520
+ _draft: {},
1521
+ _emptyState: {}
1477
1522
  }),
1478
1523
  record: (shape) => ({
1479
1524
  _type: "value",
1480
1525
  valueType: "record",
1481
1526
  shape,
1482
1527
  _plain: {},
1483
- _draft: {}
1528
+ _draft: {},
1529
+ _emptyState: {}
1484
1530
  }),
1485
1531
  array: (shape) => ({
1486
1532
  _type: "value",
1487
1533
  valueType: "array",
1488
1534
  shape,
1489
1535
  _plain: [],
1490
- _draft: []
1536
+ _draft: [],
1537
+ _emptyState: []
1491
1538
  }),
1492
1539
  // Special value type that helps make things like `string | null` representable
1493
1540
  // TODO(duane): should this be a more general type for containers too?
@@ -1496,7 +1543,43 @@ var Shape = {
1496
1543
  valueType: "union",
1497
1544
  shapes,
1498
1545
  _plain: {},
1499
- _draft: {}
1546
+ _draft: {},
1547
+ _emptyState: {}
1548
+ }),
1549
+ /**
1550
+ * Creates a discriminated union shape for type-safe tagged unions.
1551
+ *
1552
+ * @example
1553
+ * ```typescript
1554
+ * const ClientPresenceShape = Shape.plain.object({
1555
+ * type: Shape.plain.string("client"),
1556
+ * name: Shape.plain.string(),
1557
+ * input: Shape.plain.object({ force: Shape.plain.number(), angle: Shape.plain.number() }),
1558
+ * })
1559
+ *
1560
+ * const ServerPresenceShape = Shape.plain.object({
1561
+ * type: Shape.plain.string("server"),
1562
+ * cars: Shape.plain.record(Shape.plain.object({ x: Shape.plain.number(), y: Shape.plain.number() })),
1563
+ * tick: Shape.plain.number(),
1564
+ * })
1565
+ *
1566
+ * const GamePresenceSchema = Shape.plain.discriminatedUnion("type", {
1567
+ * client: ClientPresenceShape,
1568
+ * server: ServerPresenceShape,
1569
+ * })
1570
+ * ```
1571
+ *
1572
+ * @param discriminantKey - The key used to discriminate between variants (e.g., "type")
1573
+ * @param variants - A record mapping discriminant values to their object shapes
1574
+ */
1575
+ discriminatedUnion: (discriminantKey, variants) => ({
1576
+ _type: "value",
1577
+ valueType: "discriminatedUnion",
1578
+ discriminantKey,
1579
+ variants,
1580
+ _plain: {},
1581
+ _draft: {},
1582
+ _emptyState: {}
1500
1583
  })
1501
1584
  }
1502
1585
  };