@loro-extended/change 0.4.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:
@@ -318,18 +388,24 @@ const schema = Shape.doc({
318
388
  - `Shape.text()` - Collaborative text editing
319
389
  - `Shape.counter()` - Collaborative increment/decrement counters
320
390
  - `Shape.list(itemSchema)` - Collaborative ordered lists
321
- - `Shape.movableList(itemSchema)` - Collaborative Reorderable lists
322
- - `Shape.map(shape)` - Collaborative key-value maps
391
+ - `Shape.movableList(itemSchema)` - Collaborative reorderable lists
392
+ - `Shape.map(shape)` - Collaborative key-value maps with fixed keys
393
+ - `Shape.record(valueSchema)` - Collaborative key-value maps with dynamic string keys
323
394
  - `Shape.tree(shape)` - Collaborative hierarchical tree structures (Note: incomplete implementation)
324
395
 
325
396
  #### Value Types
326
397
 
327
- - `Shape.plain.string()` - String values
398
+ - `Shape.plain.string()` - String values (optionally with literal union types)
328
399
  - `Shape.plain.number()` - Number values
329
400
  - `Shape.plain.boolean()` - Boolean values
330
401
  - `Shape.plain.null()` - Null values
331
- - `Shape.plain.object(shape)` - Object values
402
+ - `Shape.plain.undefined()` - Undefined values
403
+ - `Shape.plain.uint8Array()` - Binary data values
404
+ - `Shape.plain.object(shape)` - Object values with fixed keys
405
+ - `Shape.plain.record(valueShape)` - Object values with dynamic string keys
332
406
  - `Shape.plain.array(itemShape)` - Array values
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
333
409
 
334
410
  ### TypedDoc Methods
335
411
 
@@ -404,20 +480,20 @@ Lists support familiar JavaScript array methods for filtering and finding items:
404
480
 
405
481
  ```typescript
406
482
  // Find items (returns mutable draft objects)
407
- const foundItem = draft.todos.find(todo => todo.completed);
408
- const foundIndex = draft.todos.findIndex(todo => todo.id === "123");
483
+ const foundItem = draft.todos.find((todo) => todo.completed);
484
+ const foundIndex = draft.todos.findIndex((todo) => todo.id === "123");
409
485
 
410
486
  // Filter items (returns array of mutable draft objects)
411
- const completedTodos = draft.todos.filter(todo => todo.completed);
412
- const activeTodos = draft.todos.filter(todo => !todo.completed);
487
+ const completedTodos = draft.todos.filter((todo) => todo.completed);
488
+ const activeTodos = draft.todos.filter((todo) => !todo.completed);
413
489
 
414
490
  // Transform items (returns plain array, not mutable)
415
- const todoTexts = draft.todos.map(todo => todo.text);
416
- const todoIds = draft.todos.map(todo => todo.id);
491
+ const todoTexts = draft.todos.map((todo) => todo.text);
492
+ const todoIds = draft.todos.map((todo) => todo.id);
417
493
 
418
494
  // Check conditions
419
- const hasCompleted = draft.todos.some(todo => todo.completed);
420
- const allCompleted = draft.todos.every(todo => todo.completed);
495
+ const hasCompleted = draft.todos.some((todo) => todo.completed);
496
+ const allCompleted = draft.todos.every((todo) => todo.completed);
421
497
 
422
498
  // Iterate over items
423
499
  draft.todos.forEach((todo, index) => {
@@ -430,15 +506,15 @@ draft.todos.forEach((todo, index) => {
430
506
  ```typescript
431
507
  doc.change((draft) => {
432
508
  // Find and mutate pattern - very common!
433
- const todo = draft.todos.find(t => t.id === "123");
509
+ const todo = draft.todos.find((t) => t.id === "123");
434
510
  if (todo) {
435
511
  todo.completed = true; // ✅ This mutation will persist!
436
512
  todo.text = "Updated text"; // ✅ This too!
437
513
  }
438
514
 
439
515
  // Filter and modify multiple items
440
- const activeTodos = draft.todos.filter(t => !t.completed);
441
- activeTodos.forEach(todo => {
516
+ const activeTodos = draft.todos.filter((t) => !t.completed);
517
+ activeTodos.forEach((todo) => {
442
518
  todo.priority = "high"; // ✅ All mutations persist!
443
519
  });
444
520
  });
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,37 +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 extends Shape<string, string> {
178
+ interface StringValueShape<T extends string = string> extends Shape<T, T, T> {
168
179
  readonly _type: "value";
169
180
  readonly valueType: "string";
181
+ readonly options?: T[];
170
182
  }
171
- interface NumberValueShape extends Shape<number, number> {
183
+ interface NumberValueShape extends Shape<number, number, number> {
172
184
  readonly _type: "value";
173
185
  readonly valueType: "number";
174
186
  }
175
- interface BooleanValueShape extends Shape<boolean, boolean> {
187
+ interface BooleanValueShape extends Shape<boolean, boolean, boolean> {
176
188
  readonly _type: "value";
177
189
  readonly valueType: "boolean";
178
190
  }
179
- interface NullValueShape extends Shape<null, null> {
191
+ interface NullValueShape extends Shape<null, null, null> {
180
192
  readonly _type: "value";
181
193
  readonly valueType: "null";
182
194
  }
183
- interface UndefinedValueShape extends Shape<undefined, undefined> {
195
+ interface UndefinedValueShape extends Shape<undefined, undefined, undefined> {
184
196
  readonly _type: "value";
185
197
  readonly valueType: "undefined";
186
198
  }
187
- interface Uint8ArrayValueShape extends Shape<Uint8Array, Uint8Array> {
199
+ interface Uint8ArrayValueShape extends Shape<Uint8Array, Uint8Array, Uint8Array> {
188
200
  readonly _type: "value";
189
201
  readonly valueType: "uint8array";
190
202
  }
@@ -192,32 +204,54 @@ interface ObjectValueShape<T extends Record<string, ValueShape> = Record<string,
192
204
  [K in keyof T]: T[K]["_plain"];
193
205
  }, {
194
206
  [K in keyof T]: T[K]["_draft"];
207
+ }, {
208
+ [K in keyof T]: T[K]["_emptyState"];
195
209
  }> {
196
210
  readonly _type: "value";
197
211
  readonly valueType: "object";
198
212
  readonly shape: T;
199
213
  }
200
- 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>> {
201
215
  readonly _type: "value";
202
216
  readonly valueType: "record";
203
217
  readonly shape: T;
204
218
  }
205
- 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[]> {
206
220
  readonly _type: "value";
207
221
  readonly valueType: "array";
208
222
  readonly shape: T;
209
223
  }
210
- 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"]> {
211
225
  readonly _type: "value";
212
226
  readonly valueType: "union";
213
227
  readonly shapes: T;
214
228
  }
215
- 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;
216
249
  type ContainerOrValueShape = ContainerShape | ValueShape;
217
- interface Shape<Plain, Draft> {
250
+ interface Shape<Plain, Draft, EmptyState = Plain> {
218
251
  readonly _type: string;
219
252
  readonly _plain: Plain;
220
253
  readonly _draft: Draft;
254
+ readonly _emptyState: EmptyState;
221
255
  }
222
256
  /**
223
257
  * The LoroShape factory object
@@ -236,7 +270,7 @@ declare const Shape: {
236
270
  text: () => TextContainerShape;
237
271
  tree: <T extends MapContainerShape>(shape: T) => TreeContainerShape;
238
272
  plain: {
239
- string: () => StringValueShape;
273
+ string: <T extends string = string>(...options: T[]) => StringValueShape<T>;
240
274
  number: () => NumberValueShape;
241
275
  boolean: () => BooleanValueShape;
242
276
  null: () => NullValueShape;
@@ -246,6 +280,33 @@ declare const Shape: {
246
280
  record: <T extends ValueShape>(shape: T) => RecordValueShape<T>;
247
281
  array: <T extends ValueShape>(shape: T) => ArrayValueShape<T>;
248
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>;
249
310
  };
250
311
  };
251
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;
@@ -290,7 +351,16 @@ declare class TypedDoc<Shape extends DocShape> {
290
351
  private shape;
291
352
  private emptyState;
292
353
  private doc;
293
- 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);
294
364
  get value(): InferPlainType<Shape>;
295
365
  change(fn: (draft: Draft<Shape>) => void): InferPlainType<Shape>;
296
366
  /**
@@ -313,7 +383,7 @@ declare class TypedDoc<Shape extends DocShape> {
313
383
  get docShape(): Shape;
314
384
  get rawValue(): any;
315
385
  }
316
- 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>;
317
387
 
318
388
  /**
319
389
  * Overlays CRDT state with empty state defaults
@@ -336,4 +406,4 @@ declare function mergeValue<Shape extends ContainerShape | ValueShape>(shape: Sh
336
406
  */
337
407
  declare function validateEmptyState<T extends DocShape>(emptyState: unknown, schema: T): InferPlainType<T>;
338
408
 
339
- 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
  }
@@ -1091,9 +1092,43 @@ function mergeValue(shape, crdtValue, emptyValue) {
1091
1092
  case "tree":
1092
1093
  return crdtValue ?? emptyValue ?? [];
1093
1094
  default:
1095
+ if (shape._type === "value" && shape.valueType === "object") {
1096
+ const crdtObj = crdtValue ?? {};
1097
+ const emptyObj = emptyValue ?? {};
1098
+ const result = { ...emptyObj };
1099
+ if (typeof crdtObj !== "object" || crdtObj === null) {
1100
+ return crdtValue ?? emptyValue;
1101
+ }
1102
+ for (const [key, propShape] of Object.entries(shape.shape)) {
1103
+ const propCrdt = crdtObj[key];
1104
+ const propEmpty = emptyObj[key];
1105
+ result[key] = mergeValue(propShape, propCrdt, propEmpty);
1106
+ }
1107
+ return result;
1108
+ }
1109
+ if (shape._type === "value" && shape.valueType === "discriminatedUnion") {
1110
+ return mergeDiscriminatedUnion(
1111
+ shape,
1112
+ crdtValue,
1113
+ emptyValue
1114
+ );
1115
+ }
1094
1116
  return crdtValue ?? emptyValue;
1095
1117
  }
1096
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
+ }
1097
1132
 
1098
1133
  // src/validation.ts
1099
1134
  function validateValue(value, schema, path = "") {
@@ -1168,13 +1203,20 @@ function validateValue(value, schema, path = "") {
1168
1203
  if (schema._type === "value") {
1169
1204
  const valueSchema = schema;
1170
1205
  switch (valueSchema.valueType) {
1171
- case "string":
1206
+ case "string": {
1172
1207
  if (typeof value !== "string") {
1173
1208
  throw new Error(
1174
1209
  `Expected string at path ${currentPath}, got ${typeof value}`
1175
1210
  );
1176
1211
  }
1212
+ const stringSchema = valueSchema;
1213
+ if (stringSchema.options && !stringSchema.options.includes(value)) {
1214
+ throw new Error(
1215
+ `Expected one of [${stringSchema.options.join(", ")}] at path ${currentPath}, got "${value}"`
1216
+ );
1217
+ }
1177
1218
  return value;
1219
+ }
1178
1220
  case "number":
1179
1221
  if (typeof value !== "number") {
1180
1222
  throw new Error(
@@ -1288,6 +1330,15 @@ function validateEmptyState(emptyState, schema) {
1288
1330
 
1289
1331
  // src/change.ts
1290
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
+ */
1291
1342
  constructor(shape, emptyState, doc = new LoroDoc()) {
1292
1343
  this.shape = shape;
1293
1344
  this.emptyState = emptyState;
@@ -1361,111 +1412,129 @@ var Shape = {
1361
1412
  _type: "doc",
1362
1413
  shapes: shape,
1363
1414
  _plain: {},
1364
- _draft: {}
1415
+ _draft: {},
1416
+ _emptyState: {}
1365
1417
  }),
1366
1418
  // CRDTs are represented by Loro Containers--they converge on state using Loro's
1367
1419
  // various CRDT algorithms
1368
1420
  counter: () => ({
1369
1421
  _type: "counter",
1370
1422
  _plain: 0,
1371
- _draft: {}
1423
+ _draft: {},
1424
+ _emptyState: 0
1372
1425
  }),
1373
1426
  list: (shape) => ({
1374
1427
  _type: "list",
1375
1428
  shape,
1376
1429
  _plain: [],
1377
- _draft: {}
1430
+ _draft: {},
1431
+ _emptyState: []
1378
1432
  }),
1379
1433
  map: (shape) => ({
1380
1434
  _type: "map",
1381
1435
  shapes: shape,
1382
1436
  _plain: {},
1383
- _draft: {}
1437
+ _draft: {},
1438
+ _emptyState: {}
1384
1439
  }),
1385
1440
  record: (shape) => ({
1386
1441
  _type: "record",
1387
1442
  shape,
1388
1443
  _plain: {},
1389
- _draft: {}
1444
+ _draft: {},
1445
+ _emptyState: {}
1390
1446
  }),
1391
1447
  movableList: (shape) => ({
1392
1448
  _type: "movableList",
1393
1449
  shape,
1394
1450
  _plain: [],
1395
- _draft: {}
1451
+ _draft: {},
1452
+ _emptyState: []
1396
1453
  }),
1397
1454
  text: () => ({
1398
1455
  _type: "text",
1399
1456
  _plain: "",
1400
- _draft: {}
1457
+ _draft: {},
1458
+ _emptyState: ""
1401
1459
  }),
1402
1460
  tree: (shape) => ({
1403
1461
  _type: "tree",
1404
1462
  shape,
1405
1463
  _plain: {},
1406
- _draft: {}
1464
+ _draft: {},
1465
+ _emptyState: []
1407
1466
  }),
1408
1467
  // Values are represented as plain JS objects, with the limitation that they MUST be
1409
1468
  // representable as a Loro "Value"--basically JSON. The behavior of a Value is basically
1410
1469
  // "Last Write Wins", meaning there is no subtle convergent behavior here, just taking
1411
1470
  // the most recent value based on the current available information.
1412
1471
  plain: {
1413
- string: () => ({
1472
+ string: (...options) => ({
1414
1473
  _type: "value",
1415
1474
  valueType: "string",
1416
- _plain: "",
1417
- _draft: ""
1475
+ _plain: options[0] ?? "",
1476
+ _draft: options[0] ?? "",
1477
+ _emptyState: options[0] ?? "",
1478
+ options: options.length > 0 ? options : void 0
1418
1479
  }),
1419
1480
  number: () => ({
1420
1481
  _type: "value",
1421
1482
  valueType: "number",
1422
1483
  _plain: 0,
1423
- _draft: 0
1484
+ _draft: 0,
1485
+ _emptyState: 0
1424
1486
  }),
1425
1487
  boolean: () => ({
1426
1488
  _type: "value",
1427
1489
  valueType: "boolean",
1428
1490
  _plain: false,
1429
- _draft: false
1491
+ _draft: false,
1492
+ _emptyState: false
1430
1493
  }),
1431
1494
  null: () => ({
1432
1495
  _type: "value",
1433
1496
  valueType: "null",
1434
1497
  _plain: null,
1435
- _draft: null
1498
+ _draft: null,
1499
+ _emptyState: null
1436
1500
  }),
1437
1501
  undefined: () => ({
1438
1502
  _type: "value",
1439
1503
  valueType: "undefined",
1440
1504
  _plain: void 0,
1441
- _draft: void 0
1505
+ _draft: void 0,
1506
+ _emptyState: void 0
1442
1507
  }),
1443
1508
  uint8Array: () => ({
1444
1509
  _type: "value",
1445
1510
  valueType: "uint8array",
1446
1511
  _plain: new Uint8Array(),
1447
- _draft: new Uint8Array()
1512
+ _draft: new Uint8Array(),
1513
+ _emptyState: new Uint8Array()
1448
1514
  }),
1449
1515
  object: (shape) => ({
1450
1516
  _type: "value",
1451
1517
  valueType: "object",
1452
1518
  shape,
1453
1519
  _plain: {},
1454
- _draft: {}
1520
+ _draft: {},
1521
+ _emptyState: {}
1455
1522
  }),
1456
1523
  record: (shape) => ({
1457
1524
  _type: "value",
1458
1525
  valueType: "record",
1459
1526
  shape,
1460
1527
  _plain: {},
1461
- _draft: {}
1528
+ _draft: {},
1529
+ _emptyState: {}
1462
1530
  }),
1463
1531
  array: (shape) => ({
1464
1532
  _type: "value",
1465
1533
  valueType: "array",
1466
1534
  shape,
1467
1535
  _plain: [],
1468
- _draft: []
1536
+ _draft: [],
1537
+ _emptyState: []
1469
1538
  }),
1470
1539
  // Special value type that helps make things like `string | null` representable
1471
1540
  // TODO(duane): should this be a more general type for containers too?
@@ -1474,7 +1543,43 @@ var Shape = {
1474
1543
  valueType: "union",
1475
1544
  shapes,
1476
1545
  _plain: {},
1477
- _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: {}
1478
1583
  })
1479
1584
  }
1480
1585
  };