@loro-extended/change 0.5.0 → 0.7.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,44 @@
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
+ /**
4
+ * Infers the plain (JSON-serializable) type from any Shape.
5
+ *
6
+ * This is the recommended way to extract types from shapes.
7
+ * Works with DocShape, ContainerShape, and ValueShape.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * const ChatSchema = Shape.doc({
12
+ * messages: Shape.list(Shape.map({
13
+ * id: Shape.plain.string(),
14
+ * content: Shape.text(),
15
+ * })),
16
+ * })
17
+ *
18
+ * // Extract the document type
19
+ * type ChatDoc = Infer<typeof ChatSchema>
20
+ * // Result: { messages: { id: string; content: string }[] }
21
+ *
22
+ * const PresenceSchema = Shape.plain.object({
23
+ * name: Shape.plain.string(),
24
+ * cursor: Shape.plain.object({ x: Shape.plain.number(), y: Shape.plain.number() }),
25
+ * })
26
+ *
27
+ * // Extract the presence type
28
+ * type Presence = Infer<typeof PresenceSchema>
29
+ * // Result: { name: string; cursor: { x: number; y: number } }
30
+ * ```
31
+ */
32
+ type Infer<T> = T extends Shape<infer P, any, any> ? P : never;
33
+ type InferPlainType<T> = T extends Shape<infer P, any, any> ? P : never;
34
+ type InferDraftType<T> = T extends Shape<any, infer D, any> ? D : never;
35
+ /**
36
+ * Extracts the valid empty state type from a shape.
37
+ *
38
+ * For dynamic containers (list, record, etc.), this will be constrained to
39
+ * empty values ([] or {}) to prevent users from expecting per-entry merging.
40
+ */
41
+ type InferEmptyStateType<T> = T extends Shape<any, any, infer E> ? E : never;
5
42
  type Draft<T extends DocShape<Record<string, ContainerShape>>> = InferDraftType<T>;
6
43
 
7
44
  type DraftNodeParams<Shape extends DocShape | ContainerShape> = {
@@ -128,25 +165,27 @@ interface DocShape<NestedShapes extends Record<string, ContainerShape> = Record<
128
165
  [K in keyof NestedShapes]: NestedShapes[K]["_plain"];
129
166
  }, {
130
167
  [K in keyof NestedShapes]: NestedShapes[K]["_draft"];
168
+ }, {
169
+ [K in keyof NestedShapes]: NestedShapes[K]["_emptyState"];
131
170
  }> {
132
171
  readonly _type: "doc";
133
172
  readonly shapes: NestedShapes;
134
173
  }
135
- interface TextContainerShape extends Shape<string, TextDraftNode> {
174
+ interface TextContainerShape extends Shape<string, TextDraftNode, string> {
136
175
  readonly _type: "text";
137
176
  }
138
- interface CounterContainerShape extends Shape<number, CounterDraftNode> {
177
+ interface CounterContainerShape extends Shape<number, CounterDraftNode, number> {
139
178
  readonly _type: "counter";
140
179
  }
141
- interface TreeContainerShape<NestedShape = ContainerOrValueShape> extends Shape<any, any> {
180
+ interface TreeContainerShape<NestedShape = ContainerOrValueShape> extends Shape<any, any, never[]> {
142
181
  readonly _type: "tree";
143
182
  readonly shape: NestedShape;
144
183
  }
145
- interface ListContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<NestedShape["_plain"][], ListDraftNode<NestedShape>> {
184
+ interface ListContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<NestedShape["_plain"][], ListDraftNode<NestedShape>, never[]> {
146
185
  readonly _type: "list";
147
186
  readonly shape: NestedShape;
148
187
  }
149
- interface MovableListContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<NestedShape["_plain"][], MovableListDraftNode<NestedShape>> {
188
+ interface MovableListContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<NestedShape["_plain"][], MovableListDraftNode<NestedShape>, never[]> {
150
189
  readonly _type: "movableList";
151
190
  readonly shape: NestedShape;
152
191
  }
@@ -154,38 +193,40 @@ interface MapContainerShape<NestedShapes extends Record<string, ContainerOrValue
154
193
  [K in keyof NestedShapes]: NestedShapes[K]["_plain"];
155
194
  }, MapDraftNode<NestedShapes> & {
156
195
  [K in keyof NestedShapes]: NestedShapes[K]["_draft"];
196
+ }, {
197
+ [K in keyof NestedShapes]: NestedShapes[K]["_emptyState"];
157
198
  }> {
158
199
  readonly _type: "map";
159
200
  readonly shapes: NestedShapes;
160
201
  }
161
- interface RecordContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<Record<string, NestedShape["_plain"]>, RecordDraftNode<NestedShape>> {
202
+ interface RecordContainerShape<NestedShape extends ContainerOrValueShape = ContainerOrValueShape> extends Shape<Record<string, NestedShape["_plain"]>, RecordDraftNode<NestedShape>, Record<string, never>> {
162
203
  readonly _type: "record";
163
204
  readonly shape: NestedShape;
164
205
  }
165
206
  type ContainerShape = CounterContainerShape | ListContainerShape | MapContainerShape | MovableListContainerShape | RecordContainerShape | TextContainerShape | TreeContainerShape;
166
207
  type ContainerType = ContainerShape["_type"];
167
- interface StringValueShape<T extends string = string> extends Shape<T, T> {
208
+ interface StringValueShape<T extends string = string> extends Shape<T, T, T> {
168
209
  readonly _type: "value";
169
210
  readonly valueType: "string";
170
211
  readonly options?: T[];
171
212
  }
172
- interface NumberValueShape extends Shape<number, number> {
213
+ interface NumberValueShape extends Shape<number, number, number> {
173
214
  readonly _type: "value";
174
215
  readonly valueType: "number";
175
216
  }
176
- interface BooleanValueShape extends Shape<boolean, boolean> {
217
+ interface BooleanValueShape extends Shape<boolean, boolean, boolean> {
177
218
  readonly _type: "value";
178
219
  readonly valueType: "boolean";
179
220
  }
180
- interface NullValueShape extends Shape<null, null> {
221
+ interface NullValueShape extends Shape<null, null, null> {
181
222
  readonly _type: "value";
182
223
  readonly valueType: "null";
183
224
  }
184
- interface UndefinedValueShape extends Shape<undefined, undefined> {
225
+ interface UndefinedValueShape extends Shape<undefined, undefined, undefined> {
185
226
  readonly _type: "value";
186
227
  readonly valueType: "undefined";
187
228
  }
188
- interface Uint8ArrayValueShape extends Shape<Uint8Array, Uint8Array> {
229
+ interface Uint8ArrayValueShape extends Shape<Uint8Array, Uint8Array, Uint8Array> {
189
230
  readonly _type: "value";
190
231
  readonly valueType: "uint8array";
191
232
  }
@@ -193,32 +234,54 @@ interface ObjectValueShape<T extends Record<string, ValueShape> = Record<string,
193
234
  [K in keyof T]: T[K]["_plain"];
194
235
  }, {
195
236
  [K in keyof T]: T[K]["_draft"];
237
+ }, {
238
+ [K in keyof T]: T[K]["_emptyState"];
196
239
  }> {
197
240
  readonly _type: "value";
198
241
  readonly valueType: "object";
199
242
  readonly shape: T;
200
243
  }
201
- interface RecordValueShape<T extends ValueShape = ValueShape> extends Shape<Record<string, T["_plain"]>, Record<string, T["_draft"]>> {
244
+ interface RecordValueShape<T extends ValueShape = ValueShape> extends Shape<Record<string, T["_plain"]>, Record<string, T["_draft"]>, Record<string, never>> {
202
245
  readonly _type: "value";
203
246
  readonly valueType: "record";
204
247
  readonly shape: T;
205
248
  }
206
- interface ArrayValueShape<T extends ValueShape = ValueShape> extends Shape<T["_plain"][], T["_draft"][]> {
249
+ interface ArrayValueShape<T extends ValueShape = ValueShape> extends Shape<T["_plain"][], T["_draft"][], never[]> {
207
250
  readonly _type: "value";
208
251
  readonly valueType: "array";
209
252
  readonly shape: T;
210
253
  }
211
- interface UnionValueShape<T extends ValueShape[] = ValueShape[]> extends Shape<T[number]["_plain"], T[number]["_draft"]> {
254
+ interface UnionValueShape<T extends ValueShape[] = ValueShape[]> extends Shape<T[number]["_plain"], T[number]["_draft"], T[number]["_emptyState"]> {
212
255
  readonly _type: "value";
213
256
  readonly valueType: "union";
214
257
  readonly shapes: T;
215
258
  }
216
- type ValueShape = StringValueShape | NumberValueShape | BooleanValueShape | NullValueShape | UndefinedValueShape | Uint8ArrayValueShape | ObjectValueShape | RecordValueShape | ArrayValueShape | UnionValueShape;
259
+ /**
260
+ * A discriminated union shape that uses a discriminant key to determine which variant to use.
261
+ * This enables type-safe handling of tagged unions like:
262
+ *
263
+ * ```typescript
264
+ * type GamePresence =
265
+ * | { type: "client"; name: string; input: { force: number; angle: number } }
266
+ * | { type: "server"; cars: Record<string, CarState>; tick: number }
267
+ * ```
268
+ *
269
+ * @typeParam K - The discriminant key (e.g., "type")
270
+ * @typeParam T - A record mapping discriminant values to their object shapes
271
+ */
272
+ 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"]> {
273
+ readonly _type: "value";
274
+ readonly valueType: "discriminatedUnion";
275
+ readonly discriminantKey: K;
276
+ readonly variants: T;
277
+ }
278
+ type ValueShape = StringValueShape | NumberValueShape | BooleanValueShape | NullValueShape | UndefinedValueShape | Uint8ArrayValueShape | ObjectValueShape | RecordValueShape | ArrayValueShape | UnionValueShape | DiscriminatedUnionValueShape<any, any>;
217
279
  type ContainerOrValueShape = ContainerShape | ValueShape;
218
- interface Shape<Plain, Draft> {
280
+ interface Shape<Plain, Draft, EmptyState = Plain> {
219
281
  readonly _type: string;
220
282
  readonly _plain: Plain;
221
283
  readonly _draft: Draft;
284
+ readonly _emptyState: EmptyState;
222
285
  }
223
286
  /**
224
287
  * The LoroShape factory object
@@ -247,6 +310,33 @@ declare const Shape: {
247
310
  record: <T extends ValueShape>(shape: T) => RecordValueShape<T>;
248
311
  array: <T extends ValueShape>(shape: T) => ArrayValueShape<T>;
249
312
  union: <T extends ValueShape[]>(shapes: T) => UnionValueShape<T>;
313
+ /**
314
+ * Creates a discriminated union shape for type-safe tagged unions.
315
+ *
316
+ * @example
317
+ * ```typescript
318
+ * const ClientPresenceShape = Shape.plain.object({
319
+ * type: Shape.plain.string("client"),
320
+ * name: Shape.plain.string(),
321
+ * input: Shape.plain.object({ force: Shape.plain.number(), angle: Shape.plain.number() }),
322
+ * })
323
+ *
324
+ * const ServerPresenceShape = Shape.plain.object({
325
+ * type: Shape.plain.string("server"),
326
+ * cars: Shape.plain.record(Shape.plain.object({ x: Shape.plain.number(), y: Shape.plain.number() })),
327
+ * tick: Shape.plain.number(),
328
+ * })
329
+ *
330
+ * const GamePresenceSchema = Shape.plain.discriminatedUnion("type", {
331
+ * client: ClientPresenceShape,
332
+ * server: ServerPresenceShape,
333
+ * })
334
+ * ```
335
+ *
336
+ * @param discriminantKey - The key used to discriminate between variants (e.g., "type")
337
+ * @param variants - A record mapping discriminant values to their object shapes
338
+ */
339
+ discriminatedUnion: <K extends string, T extends Record<string, ObjectValueShape>>(discriminantKey: K, variants: T) => DiscriminatedUnionValueShape<K, T>;
250
340
  };
251
341
  };
252
342
  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 +381,16 @@ declare class TypedDoc<Shape extends DocShape> {
291
381
  private shape;
292
382
  private emptyState;
293
383
  private doc;
294
- constructor(shape: Shape, emptyState: InferPlainType<Shape>, doc?: LoroDoc);
384
+ /**
385
+ * Creates a new TypedDoc with the given schema and empty state.
386
+ *
387
+ * @param shape - The document schema
388
+ * @param emptyState - Default values for the document. For dynamic containers
389
+ * (list, record, etc.), only empty values ([] or {}) are allowed. Use
390
+ * `.change()` to add initial data after construction.
391
+ * @param doc - Optional existing LoroDoc to wrap
392
+ */
393
+ constructor(shape: Shape, emptyState: InferEmptyStateType<Shape>, doc?: LoroDoc);
295
394
  get value(): InferPlainType<Shape>;
296
395
  change(fn: (draft: Draft<Shape>) => void): InferPlainType<Shape>;
297
396
  /**
@@ -314,7 +413,7 @@ declare class TypedDoc<Shape extends DocShape> {
314
413
  get docShape(): Shape;
315
414
  get rawValue(): any;
316
415
  }
317
- declare function createTypedDoc<Shape extends DocShape>(shape: Shape, emptyState: InferPlainType<Shape>, existingDoc?: LoroDoc): TypedDoc<Shape>;
416
+ declare function createTypedDoc<Shape extends DocShape>(shape: Shape, emptyState: InferEmptyStateType<Shape>, existingDoc?: LoroDoc): TypedDoc<Shape>;
318
417
 
319
418
  /**
320
419
  * Overlays CRDT state with empty state defaults
@@ -337,4 +436,4 @@ declare function mergeValue<Shape extends ContainerShape | ValueShape>(shape: Sh
337
436
  */
338
437
  declare function validateEmptyState<T extends DocShape>(emptyState: unknown, schema: T): InferPlainType<T>;
339
438
 
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 };
439
+ export { type ArrayValueShape, type ContainerOrValueShape, type ContainerShape, type CounterContainerShape, type DiscriminatedUnionValueShape, type DocShape, type Draft, type Infer, 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
@@ -63,7 +63,8 @@ function isValueShape(schema) {
63
63
  "object",
64
64
  "record",
65
65
  "array",
66
- "union"
66
+ "union",
67
+ "discriminatedUnion"
67
68
  ].includes(schema.valueType);
68
69
  }
69
70
  function isObjectValue(value) {
@@ -487,13 +488,13 @@ var MapDraftNode = class extends DraftNode {
487
488
  node = containerValue;
488
489
  } else {
489
490
  const emptyState = this.emptyState?.[key];
490
- if (!emptyState) {
491
+ if (emptyState === void 0) {
491
492
  throw new Error("empty state required");
492
493
  }
493
494
  node = emptyState;
494
495
  }
495
496
  }
496
- if (!node) throw new Error("no container made");
497
+ if (node === void 0) throw new Error("no container made");
497
498
  this.propertyCache.set(key, node);
498
499
  }
499
500
  return node;
@@ -505,6 +506,7 @@ var MapDraftNode = class extends DraftNode {
505
506
  get: () => this.getOrCreateNode(key, shape),
506
507
  set: isValueShape(shape) ? (value) => {
507
508
  this.container.set(key, value);
509
+ this.propertyCache.set(key, value);
508
510
  } : void 0
509
511
  });
510
512
  }
@@ -1105,9 +1107,31 @@ function mergeValue(shape, crdtValue, emptyValue) {
1105
1107
  }
1106
1108
  return result;
1107
1109
  }
1110
+ if (shape._type === "value" && shape.valueType === "discriminatedUnion") {
1111
+ return mergeDiscriminatedUnion(
1112
+ shape,
1113
+ crdtValue,
1114
+ emptyValue
1115
+ );
1116
+ }
1108
1117
  return crdtValue ?? emptyValue;
1109
1118
  }
1110
1119
  }
1120
+ function mergeDiscriminatedUnion(shape, crdtValue, emptyValue) {
1121
+ const crdtObj = crdtValue ?? {};
1122
+ const emptyObj = emptyValue ?? {};
1123
+ const discriminantValue = crdtObj[shape.discriminantKey] ?? emptyObj[shape.discriminantKey];
1124
+ if (typeof discriminantValue !== "string") {
1125
+ return emptyValue;
1126
+ }
1127
+ const variantShape = shape.variants[discriminantValue];
1128
+ if (!variantShape) {
1129
+ return crdtValue ?? emptyValue;
1130
+ }
1131
+ const emptyDiscriminant = emptyObj[shape.discriminantKey];
1132
+ const effectiveEmptyValue = emptyDiscriminant === discriminantValue ? emptyValue : void 0;
1133
+ return mergeValue(variantShape, crdtValue, effectiveEmptyValue);
1134
+ }
1111
1135
 
1112
1136
  // src/validation.ts
1113
1137
  function validateValue(value, schema, path = "") {
@@ -1289,6 +1313,30 @@ function validateValue(value, schema, path = "") {
1289
1313
  `Value at path ${currentPath} does not match any union type: ${lastError?.message}`
1290
1314
  );
1291
1315
  }
1316
+ case "discriminatedUnion": {
1317
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1318
+ throw new Error(
1319
+ `Expected object at path ${currentPath}, got ${typeof value}`
1320
+ );
1321
+ }
1322
+ const unionSchema = valueSchema;
1323
+ const discriminantKey = unionSchema.discriminantKey;
1324
+ const discriminantValue = value[discriminantKey];
1325
+ if (typeof discriminantValue !== "string") {
1326
+ throw new Error(
1327
+ `Expected string for discriminant key "${discriminantKey}" at path ${currentPath}, got ${typeof discriminantValue}`
1328
+ );
1329
+ }
1330
+ const variantSchema = unionSchema.variants[discriminantValue];
1331
+ if (!variantSchema) {
1332
+ throw new Error(
1333
+ `Invalid discriminant value "${discriminantValue}" at path ${currentPath}. Expected one of: ${Object.keys(
1334
+ unionSchema.variants
1335
+ ).join(", ")}`
1336
+ );
1337
+ }
1338
+ return validateValue(value, variantSchema, currentPath);
1339
+ }
1292
1340
  default:
1293
1341
  throw new Error(`Unknown value type: ${valueSchema.valueType}`);
1294
1342
  }
@@ -1309,6 +1357,15 @@ function validateEmptyState(emptyState, schema) {
1309
1357
 
1310
1358
  // src/change.ts
1311
1359
  var TypedDoc = class {
1360
+ /**
1361
+ * Creates a new TypedDoc with the given schema and empty state.
1362
+ *
1363
+ * @param shape - The document schema
1364
+ * @param emptyState - Default values for the document. For dynamic containers
1365
+ * (list, record, etc.), only empty values ([] or {}) are allowed. Use
1366
+ * `.change()` to add initial data after construction.
1367
+ * @param doc - Optional existing LoroDoc to wrap
1368
+ */
1312
1369
  constructor(shape, emptyState, doc = new LoroDoc()) {
1313
1370
  this.shape = shape;
1314
1371
  this.emptyState = emptyState;
@@ -1382,49 +1439,57 @@ var Shape = {
1382
1439
  _type: "doc",
1383
1440
  shapes: shape,
1384
1441
  _plain: {},
1385
- _draft: {}
1442
+ _draft: {},
1443
+ _emptyState: {}
1386
1444
  }),
1387
1445
  // CRDTs are represented by Loro Containers--they converge on state using Loro's
1388
1446
  // various CRDT algorithms
1389
1447
  counter: () => ({
1390
1448
  _type: "counter",
1391
1449
  _plain: 0,
1392
- _draft: {}
1450
+ _draft: {},
1451
+ _emptyState: 0
1393
1452
  }),
1394
1453
  list: (shape) => ({
1395
1454
  _type: "list",
1396
1455
  shape,
1397
1456
  _plain: [],
1398
- _draft: {}
1457
+ _draft: {},
1458
+ _emptyState: []
1399
1459
  }),
1400
1460
  map: (shape) => ({
1401
1461
  _type: "map",
1402
1462
  shapes: shape,
1403
1463
  _plain: {},
1404
- _draft: {}
1464
+ _draft: {},
1465
+ _emptyState: {}
1405
1466
  }),
1406
1467
  record: (shape) => ({
1407
1468
  _type: "record",
1408
1469
  shape,
1409
1470
  _plain: {},
1410
- _draft: {}
1471
+ _draft: {},
1472
+ _emptyState: {}
1411
1473
  }),
1412
1474
  movableList: (shape) => ({
1413
1475
  _type: "movableList",
1414
1476
  shape,
1415
1477
  _plain: [],
1416
- _draft: {}
1478
+ _draft: {},
1479
+ _emptyState: []
1417
1480
  }),
1418
1481
  text: () => ({
1419
1482
  _type: "text",
1420
1483
  _plain: "",
1421
- _draft: {}
1484
+ _draft: {},
1485
+ _emptyState: ""
1422
1486
  }),
1423
1487
  tree: (shape) => ({
1424
1488
  _type: "tree",
1425
1489
  shape,
1426
1490
  _plain: {},
1427
- _draft: {}
1491
+ _draft: {},
1492
+ _emptyState: []
1428
1493
  }),
1429
1494
  // Values are represented as plain JS objects, with the limitation that they MUST be
1430
1495
  // representable as a Loro "Value"--basically JSON. The behavior of a Value is basically
@@ -1436,58 +1501,67 @@ var Shape = {
1436
1501
  valueType: "string",
1437
1502
  _plain: options[0] ?? "",
1438
1503
  _draft: options[0] ?? "",
1504
+ _emptyState: options[0] ?? "",
1439
1505
  options: options.length > 0 ? options : void 0
1440
1506
  }),
1441
1507
  number: () => ({
1442
1508
  _type: "value",
1443
1509
  valueType: "number",
1444
1510
  _plain: 0,
1445
- _draft: 0
1511
+ _draft: 0,
1512
+ _emptyState: 0
1446
1513
  }),
1447
1514
  boolean: () => ({
1448
1515
  _type: "value",
1449
1516
  valueType: "boolean",
1450
1517
  _plain: false,
1451
- _draft: false
1518
+ _draft: false,
1519
+ _emptyState: false
1452
1520
  }),
1453
1521
  null: () => ({
1454
1522
  _type: "value",
1455
1523
  valueType: "null",
1456
1524
  _plain: null,
1457
- _draft: null
1525
+ _draft: null,
1526
+ _emptyState: null
1458
1527
  }),
1459
1528
  undefined: () => ({
1460
1529
  _type: "value",
1461
1530
  valueType: "undefined",
1462
1531
  _plain: void 0,
1463
- _draft: void 0
1532
+ _draft: void 0,
1533
+ _emptyState: void 0
1464
1534
  }),
1465
1535
  uint8Array: () => ({
1466
1536
  _type: "value",
1467
1537
  valueType: "uint8array",
1468
1538
  _plain: new Uint8Array(),
1469
- _draft: new Uint8Array()
1539
+ _draft: new Uint8Array(),
1540
+ _emptyState: new Uint8Array()
1470
1541
  }),
1471
1542
  object: (shape) => ({
1472
1543
  _type: "value",
1473
1544
  valueType: "object",
1474
1545
  shape,
1475
1546
  _plain: {},
1476
- _draft: {}
1547
+ _draft: {},
1548
+ _emptyState: {}
1477
1549
  }),
1478
1550
  record: (shape) => ({
1479
1551
  _type: "value",
1480
1552
  valueType: "record",
1481
1553
  shape,
1482
1554
  _plain: {},
1483
- _draft: {}
1555
+ _draft: {},
1556
+ _emptyState: {}
1484
1557
  }),
1485
1558
  array: (shape) => ({
1486
1559
  _type: "value",
1487
1560
  valueType: "array",
1488
1561
  shape,
1489
1562
  _plain: [],
1490
- _draft: []
1563
+ _draft: [],
1564
+ _emptyState: []
1491
1565
  }),
1492
1566
  // Special value type that helps make things like `string | null` representable
1493
1567
  // TODO(duane): should this be a more general type for containers too?
@@ -1496,7 +1570,43 @@ var Shape = {
1496
1570
  valueType: "union",
1497
1571
  shapes,
1498
1572
  _plain: {},
1499
- _draft: {}
1573
+ _draft: {},
1574
+ _emptyState: {}
1575
+ }),
1576
+ /**
1577
+ * Creates a discriminated union shape for type-safe tagged unions.
1578
+ *
1579
+ * @example
1580
+ * ```typescript
1581
+ * const ClientPresenceShape = Shape.plain.object({
1582
+ * type: Shape.plain.string("client"),
1583
+ * name: Shape.plain.string(),
1584
+ * input: Shape.plain.object({ force: Shape.plain.number(), angle: Shape.plain.number() }),
1585
+ * })
1586
+ *
1587
+ * const ServerPresenceShape = Shape.plain.object({
1588
+ * type: Shape.plain.string("server"),
1589
+ * cars: Shape.plain.record(Shape.plain.object({ x: Shape.plain.number(), y: Shape.plain.number() })),
1590
+ * tick: Shape.plain.number(),
1591
+ * })
1592
+ *
1593
+ * const GamePresenceSchema = Shape.plain.discriminatedUnion("type", {
1594
+ * client: ClientPresenceShape,
1595
+ * server: ServerPresenceShape,
1596
+ * })
1597
+ * ```
1598
+ *
1599
+ * @param discriminantKey - The key used to discriminate between variants (e.g., "type")
1600
+ * @param variants - A record mapping discriminant values to their object shapes
1601
+ */
1602
+ discriminatedUnion: (discriminantKey, variants) => ({
1603
+ _type: "value",
1604
+ valueType: "discriminatedUnion",
1605
+ discriminantKey,
1606
+ variants,
1607
+ _plain: {},
1608
+ _draft: {},
1609
+ _emptyState: {}
1500
1610
  })
1501
1611
  }
1502
1612
  };