@loro-extended/change 1.0.0 → 1.1.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 +98 -60
- package/dist/index.d.ts +15 -8
- package/dist/index.js +92 -32
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/change.test.ts +251 -0
- package/src/index.ts +2 -0
- package/src/shape.ts +136 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loro-extended/change",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A schema-driven, type-safe wrapper for Loro CRDT that provides natural JavaScript syntax for collaborative data mutations",
|
|
5
5
|
"author": "Duane Johnson",
|
|
6
6
|
"license": "MIT",
|
package/src/change.test.ts
CHANGED
|
@@ -737,6 +737,257 @@ describe("TypedLoroDoc", () => {
|
|
|
737
737
|
expect(result.profile.email).toBe("john@example.com")
|
|
738
738
|
expect(result.profile.age).toBeNull()
|
|
739
739
|
})
|
|
740
|
+
|
|
741
|
+
describe("Nullable Builder", () => {
|
|
742
|
+
describe("Basic nullable types", () => {
|
|
743
|
+
it("should handle nullable string with null placeholder", () => {
|
|
744
|
+
const schema = Shape.doc({
|
|
745
|
+
profile: Shape.struct({
|
|
746
|
+
email: Shape.plain.string().nullable(),
|
|
747
|
+
}),
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
const typedDoc = createTypedDoc(schema)
|
|
751
|
+
|
|
752
|
+
// Should start with null placeholder
|
|
753
|
+
expect(typedDoc.toJSON().profile.email).toBeNull()
|
|
754
|
+
|
|
755
|
+
// Should accept string value
|
|
756
|
+
const result = change(typedDoc, draft => {
|
|
757
|
+
draft.profile.set("email", "test@example.com")
|
|
758
|
+
}).toJSON()
|
|
759
|
+
|
|
760
|
+
expect(result.profile.email).toBe("test@example.com")
|
|
761
|
+
|
|
762
|
+
// Should accept null value
|
|
763
|
+
const result2 = change(typedDoc, draft => {
|
|
764
|
+
draft.profile.set("email", null)
|
|
765
|
+
}).toJSON()
|
|
766
|
+
|
|
767
|
+
expect(result2.profile.email).toBeNull()
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it("should handle nullable number with null placeholder", () => {
|
|
771
|
+
const schema = Shape.doc({
|
|
772
|
+
stats: Shape.struct({
|
|
773
|
+
age: Shape.plain.number().nullable(),
|
|
774
|
+
}),
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
const typedDoc = createTypedDoc(schema)
|
|
778
|
+
|
|
779
|
+
expect(typedDoc.toJSON().stats.age).toBeNull()
|
|
780
|
+
|
|
781
|
+
const result = change(typedDoc, draft => {
|
|
782
|
+
draft.stats.set("age", 25)
|
|
783
|
+
}).toJSON()
|
|
784
|
+
|
|
785
|
+
expect(result.stats.age).toBe(25)
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
it("should handle nullable boolean with null placeholder", () => {
|
|
789
|
+
const schema = Shape.doc({
|
|
790
|
+
settings: Shape.struct({
|
|
791
|
+
enabled: Shape.plain.boolean().nullable(),
|
|
792
|
+
}),
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
const typedDoc = createTypedDoc(schema)
|
|
796
|
+
|
|
797
|
+
expect(typedDoc.toJSON().settings.enabled).toBeNull()
|
|
798
|
+
|
|
799
|
+
const result = change(typedDoc, draft => {
|
|
800
|
+
draft.settings.set("enabled", true)
|
|
801
|
+
}).toJSON()
|
|
802
|
+
|
|
803
|
+
expect(result.settings.enabled).toBe(true)
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
it("should handle nullable record with null placeholder", () => {
|
|
807
|
+
const schema = Shape.doc({
|
|
808
|
+
data: Shape.struct({
|
|
809
|
+
candidates: Shape.plain.record(Shape.plain.string()).nullable(),
|
|
810
|
+
}),
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
const typedDoc = createTypedDoc(schema)
|
|
814
|
+
|
|
815
|
+
expect(typedDoc.toJSON().data.candidates).toBeNull()
|
|
816
|
+
|
|
817
|
+
const result = change(typedDoc, draft => {
|
|
818
|
+
draft.data.set("candidates", { a: "Alice", b: "Bob" })
|
|
819
|
+
}).toJSON()
|
|
820
|
+
|
|
821
|
+
expect(result.data.candidates).toEqual({ a: "Alice", b: "Bob" })
|
|
822
|
+
|
|
823
|
+
// Should accept null value
|
|
824
|
+
const result2 = change(typedDoc, draft => {
|
|
825
|
+
draft.data.set("candidates", null)
|
|
826
|
+
}).toJSON()
|
|
827
|
+
|
|
828
|
+
expect(result2.data.candidates).toBeNull()
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it("should handle nullable array with null placeholder", () => {
|
|
832
|
+
const schema = Shape.doc({
|
|
833
|
+
data: Shape.struct({
|
|
834
|
+
tags: Shape.plain.array(Shape.plain.string()).nullable(),
|
|
835
|
+
}),
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
const typedDoc = createTypedDoc(schema)
|
|
839
|
+
|
|
840
|
+
expect(typedDoc.toJSON().data.tags).toBeNull()
|
|
841
|
+
|
|
842
|
+
const result = change(typedDoc, draft => {
|
|
843
|
+
draft.data.set("tags", ["a", "b", "c"])
|
|
844
|
+
}).toJSON()
|
|
845
|
+
|
|
846
|
+
expect(result.data.tags).toEqual(["a", "b", "c"])
|
|
847
|
+
|
|
848
|
+
// Should accept null value
|
|
849
|
+
const result2 = change(typedDoc, draft => {
|
|
850
|
+
draft.data.set("tags", null)
|
|
851
|
+
}).toJSON()
|
|
852
|
+
|
|
853
|
+
expect(result2.data.tags).toBeNull()
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
it("should handle nullable struct with null placeholder", () => {
|
|
857
|
+
const schema = Shape.doc({
|
|
858
|
+
data: Shape.struct({
|
|
859
|
+
point: Shape.plain
|
|
860
|
+
.struct({
|
|
861
|
+
x: Shape.plain.number(),
|
|
862
|
+
y: Shape.plain.number(),
|
|
863
|
+
})
|
|
864
|
+
.nullable(),
|
|
865
|
+
}),
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
const typedDoc = createTypedDoc(schema)
|
|
869
|
+
|
|
870
|
+
expect(typedDoc.toJSON().data.point).toBeNull()
|
|
871
|
+
|
|
872
|
+
const result = change(typedDoc, draft => {
|
|
873
|
+
draft.data.set("point", { x: 10, y: 20 })
|
|
874
|
+
}).toJSON()
|
|
875
|
+
|
|
876
|
+
expect(result.data.point).toEqual({ x: 10, y: 20 })
|
|
877
|
+
|
|
878
|
+
// Should accept null value
|
|
879
|
+
const result2 = change(typedDoc, draft => {
|
|
880
|
+
draft.data.set("point", null)
|
|
881
|
+
}).toJSON()
|
|
882
|
+
|
|
883
|
+
expect(result2.data.point).toBeNull()
|
|
884
|
+
})
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
describe("Nullable with custom placeholder", () => {
|
|
888
|
+
it("should allow custom placeholder after nullable()", () => {
|
|
889
|
+
const schema = Shape.doc({
|
|
890
|
+
profile: Shape.struct({
|
|
891
|
+
name: Shape.plain.string().nullable().placeholder("Anonymous"),
|
|
892
|
+
}),
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
const typedDoc = createTypedDoc(schema)
|
|
896
|
+
|
|
897
|
+
// Should use custom placeholder, not null
|
|
898
|
+
expect(typedDoc.toJSON().profile.name).toBe("Anonymous")
|
|
899
|
+
|
|
900
|
+
// Should still accept null
|
|
901
|
+
const result = change(typedDoc, draft => {
|
|
902
|
+
draft.profile.set("name", null)
|
|
903
|
+
}).toJSON()
|
|
904
|
+
|
|
905
|
+
expect(result.profile.name).toBeNull()
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
it("should allow number placeholder after nullable()", () => {
|
|
909
|
+
const schema = Shape.doc({
|
|
910
|
+
stats: Shape.struct({
|
|
911
|
+
score: Shape.plain.number().nullable().placeholder(0),
|
|
912
|
+
}),
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
const typedDoc = createTypedDoc(schema)
|
|
916
|
+
|
|
917
|
+
expect(typedDoc.toJSON().stats.score).toBe(0)
|
|
918
|
+
})
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
describe("Type inference", () => {
|
|
922
|
+
it("should infer correct types for nullable fields", () => {
|
|
923
|
+
const schema = Shape.doc({
|
|
924
|
+
data: Shape.struct({
|
|
925
|
+
nullableString: Shape.plain.string().nullable(),
|
|
926
|
+
nullableNumber: Shape.plain.number().nullable(),
|
|
927
|
+
nullableBoolean: Shape.plain.boolean().nullable(),
|
|
928
|
+
}),
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
const typedDoc = createTypedDoc(schema)
|
|
932
|
+
|
|
933
|
+
// TypeScript should allow these assignments
|
|
934
|
+
change(typedDoc, draft => {
|
|
935
|
+
draft.data.set("nullableString", "hello")
|
|
936
|
+
draft.data.set("nullableString", null)
|
|
937
|
+
draft.data.set("nullableNumber", 42)
|
|
938
|
+
draft.data.set("nullableNumber", null)
|
|
939
|
+
draft.data.set("nullableBoolean", true)
|
|
940
|
+
draft.data.set("nullableBoolean", null)
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
// Verify the types work correctly
|
|
944
|
+
const json = typedDoc.toJSON()
|
|
945
|
+
const str: string | null = json.data.nullableString
|
|
946
|
+
const num: number | null = json.data.nullableNumber
|
|
947
|
+
const bool: boolean | null = json.data.nullableBoolean
|
|
948
|
+
|
|
949
|
+
expect(str).toBeNull()
|
|
950
|
+
expect(num).toBeNull()
|
|
951
|
+
expect(bool).toBeNull()
|
|
952
|
+
})
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
describe("Equivalence to union pattern", () => {
|
|
956
|
+
it("should behave identically to manual union pattern", () => {
|
|
957
|
+
// Using nullable()
|
|
958
|
+
const schema1 = Shape.doc({
|
|
959
|
+
profile: Shape.struct({
|
|
960
|
+
email: Shape.plain.string().nullable(),
|
|
961
|
+
}),
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
// Using manual union
|
|
965
|
+
const schema2 = Shape.doc({
|
|
966
|
+
profile: Shape.struct({
|
|
967
|
+
email: Shape.plain
|
|
968
|
+
.union([Shape.plain.null(), Shape.plain.string()])
|
|
969
|
+
.placeholder(null),
|
|
970
|
+
}),
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
const doc1 = createTypedDoc(schema1)
|
|
974
|
+
const doc2 = createTypedDoc(schema2)
|
|
975
|
+
|
|
976
|
+
// Both should have same initial state
|
|
977
|
+
expect(doc1.toJSON()).toEqual(doc2.toJSON())
|
|
978
|
+
|
|
979
|
+
// Both should accept same operations
|
|
980
|
+
change(doc1, draft => {
|
|
981
|
+
draft.profile.set("email", "test@example.com")
|
|
982
|
+
})
|
|
983
|
+
change(doc2, draft => {
|
|
984
|
+
draft.profile.set("email", "test@example.com")
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
expect(doc1.toJSON()).toEqual(doc2.toJSON())
|
|
988
|
+
})
|
|
989
|
+
})
|
|
990
|
+
})
|
|
740
991
|
})
|
|
741
992
|
|
|
742
993
|
describe("Raw vs Overlaid Values", () => {
|
package/src/index.ts
CHANGED
package/src/shape.ts
CHANGED
|
@@ -28,6 +28,14 @@ export type WithPlaceholder<S extends Shape<any, any, any>> = S & {
|
|
|
28
28
|
placeholder(value: S["_placeholder"]): S
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Type for value shapes that support the .nullable() method.
|
|
33
|
+
* Returns a union of null and the original shape with null as the default placeholder.
|
|
34
|
+
*/
|
|
35
|
+
export type WithNullable<S extends ValueShape> = {
|
|
36
|
+
nullable(): WithPlaceholder<UnionValueShape<[NullValueShape, S]>>
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
export interface DocShape<
|
|
32
40
|
NestedShapes extends Record<string, ContainerShape> = Record<
|
|
33
41
|
string,
|
|
@@ -258,6 +266,41 @@ export type ValueShape =
|
|
|
258
266
|
|
|
259
267
|
export type ContainerOrValueShape = ContainerShape | ValueShape
|
|
260
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Creates a nullable version of a value shape.
|
|
271
|
+
* @internal
|
|
272
|
+
*/
|
|
273
|
+
function makeNullable<S extends ValueShape>(
|
|
274
|
+
shape: S,
|
|
275
|
+
): WithPlaceholder<UnionValueShape<[NullValueShape, S]>> {
|
|
276
|
+
const nullShape: NullValueShape = {
|
|
277
|
+
_type: "value" as const,
|
|
278
|
+
valueType: "null" as const,
|
|
279
|
+
_plain: null,
|
|
280
|
+
_mutable: null,
|
|
281
|
+
_placeholder: null,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const base: UnionValueShape<[NullValueShape, S]> = {
|
|
285
|
+
_type: "value" as const,
|
|
286
|
+
valueType: "union" as const,
|
|
287
|
+
shapes: [nullShape, shape] as [NullValueShape, S],
|
|
288
|
+
_plain: null as any,
|
|
289
|
+
_mutable: null as any,
|
|
290
|
+
_placeholder: null as any, // Default placeholder is null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return Object.assign(base, {
|
|
294
|
+
placeholder(
|
|
295
|
+
value: S["_placeholder"] | null,
|
|
296
|
+
): UnionValueShape<[NullValueShape, S]> {
|
|
297
|
+
return { ...base, _placeholder: value } as UnionValueShape<
|
|
298
|
+
[NullValueShape, S]
|
|
299
|
+
>
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
261
304
|
/**
|
|
262
305
|
* The LoroShape factory object
|
|
263
306
|
*
|
|
@@ -386,7 +429,8 @@ export const Shape = {
|
|
|
386
429
|
plain: {
|
|
387
430
|
string: <T extends string = string>(
|
|
388
431
|
...options: T[]
|
|
389
|
-
): WithPlaceholder<StringValueShape<T>>
|
|
432
|
+
): WithPlaceholder<StringValueShape<T>> &
|
|
433
|
+
WithNullable<StringValueShape<T>> => {
|
|
390
434
|
const base: StringValueShape<T> = {
|
|
391
435
|
_type: "value" as const,
|
|
392
436
|
valueType: "string" as const,
|
|
@@ -399,10 +443,16 @@ export const Shape = {
|
|
|
399
443
|
placeholder(value: T): StringValueShape<T> {
|
|
400
444
|
return { ...base, _placeholder: value }
|
|
401
445
|
},
|
|
446
|
+
nullable(): WithPlaceholder<
|
|
447
|
+
UnionValueShape<[NullValueShape, StringValueShape<T>]>
|
|
448
|
+
> {
|
|
449
|
+
return makeNullable(base)
|
|
450
|
+
},
|
|
402
451
|
})
|
|
403
452
|
},
|
|
404
453
|
|
|
405
|
-
number: (): WithPlaceholder<NumberValueShape>
|
|
454
|
+
number: (): WithPlaceholder<NumberValueShape> &
|
|
455
|
+
WithNullable<NumberValueShape> => {
|
|
406
456
|
const base: NumberValueShape = {
|
|
407
457
|
_type: "value" as const,
|
|
408
458
|
valueType: "number" as const,
|
|
@@ -414,10 +464,16 @@ export const Shape = {
|
|
|
414
464
|
placeholder(value: number): NumberValueShape {
|
|
415
465
|
return { ...base, _placeholder: value }
|
|
416
466
|
},
|
|
467
|
+
nullable(): WithPlaceholder<
|
|
468
|
+
UnionValueShape<[NullValueShape, NumberValueShape]>
|
|
469
|
+
> {
|
|
470
|
+
return makeNullable(base)
|
|
471
|
+
},
|
|
417
472
|
})
|
|
418
473
|
},
|
|
419
474
|
|
|
420
|
-
boolean: (): WithPlaceholder<BooleanValueShape>
|
|
475
|
+
boolean: (): WithPlaceholder<BooleanValueShape> &
|
|
476
|
+
WithNullable<BooleanValueShape> => {
|
|
421
477
|
const base: BooleanValueShape = {
|
|
422
478
|
_type: "value" as const,
|
|
423
479
|
valueType: "boolean" as const,
|
|
@@ -429,6 +485,11 @@ export const Shape = {
|
|
|
429
485
|
placeholder(value: boolean): BooleanValueShape {
|
|
430
486
|
return { ...base, _placeholder: value }
|
|
431
487
|
},
|
|
488
|
+
nullable(): WithPlaceholder<
|
|
489
|
+
UnionValueShape<[NullValueShape, BooleanValueShape]>
|
|
490
|
+
> {
|
|
491
|
+
return makeNullable(base)
|
|
492
|
+
},
|
|
432
493
|
})
|
|
433
494
|
},
|
|
434
495
|
|
|
@@ -470,46 +531,86 @@ export const Shape = {
|
|
|
470
531
|
*/
|
|
471
532
|
struct: <T extends Record<string, ValueShape>>(
|
|
472
533
|
shape: T,
|
|
473
|
-
): StructValueShape<T> =>
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
534
|
+
): StructValueShape<T> & WithNullable<StructValueShape<T>> => {
|
|
535
|
+
const base: StructValueShape<T> = {
|
|
536
|
+
_type: "value" as const,
|
|
537
|
+
valueType: "struct" as const,
|
|
538
|
+
shape,
|
|
539
|
+
_plain: {} as any,
|
|
540
|
+
_mutable: {} as any,
|
|
541
|
+
_placeholder: {} as any,
|
|
542
|
+
}
|
|
543
|
+
return Object.assign(base, {
|
|
544
|
+
nullable(): WithPlaceholder<
|
|
545
|
+
UnionValueShape<[NullValueShape, StructValueShape<T>]>
|
|
546
|
+
> {
|
|
547
|
+
return makeNullable(base)
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
},
|
|
481
551
|
|
|
482
552
|
/**
|
|
483
553
|
* @deprecated Use `Shape.plain.struct` instead. `Shape.plain.struct` will be removed in a future version.
|
|
484
554
|
*/
|
|
485
555
|
object: <T extends Record<string, ValueShape>>(
|
|
486
556
|
shape: T,
|
|
487
|
-
): StructValueShape<T> =>
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
557
|
+
): StructValueShape<T> & WithNullable<StructValueShape<T>> => {
|
|
558
|
+
const base: StructValueShape<T> = {
|
|
559
|
+
_type: "value" as const,
|
|
560
|
+
valueType: "struct" as const,
|
|
561
|
+
shape,
|
|
562
|
+
_plain: {} as any,
|
|
563
|
+
_mutable: {} as any,
|
|
564
|
+
_placeholder: {} as any,
|
|
565
|
+
}
|
|
566
|
+
return Object.assign(base, {
|
|
567
|
+
nullable(): WithPlaceholder<
|
|
568
|
+
UnionValueShape<[NullValueShape, StructValueShape<T>]>
|
|
569
|
+
> {
|
|
570
|
+
return makeNullable(base)
|
|
571
|
+
},
|
|
572
|
+
})
|
|
573
|
+
},
|
|
495
574
|
|
|
496
|
-
record: <T extends ValueShape>(
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
575
|
+
record: <T extends ValueShape>(
|
|
576
|
+
shape: T,
|
|
577
|
+
): RecordValueShape<T> & WithNullable<RecordValueShape<T>> => {
|
|
578
|
+
const base: RecordValueShape<T> = {
|
|
579
|
+
_type: "value" as const,
|
|
580
|
+
valueType: "record" as const,
|
|
581
|
+
shape,
|
|
582
|
+
_plain: {} as any,
|
|
583
|
+
_mutable: {} as any,
|
|
584
|
+
_placeholder: {} as Record<string, never>,
|
|
585
|
+
}
|
|
586
|
+
return Object.assign(base, {
|
|
587
|
+
nullable(): WithPlaceholder<
|
|
588
|
+
UnionValueShape<[NullValueShape, RecordValueShape<T>]>
|
|
589
|
+
> {
|
|
590
|
+
return makeNullable(base)
|
|
591
|
+
},
|
|
592
|
+
})
|
|
593
|
+
},
|
|
504
594
|
|
|
505
|
-
array: <T extends ValueShape>(
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
595
|
+
array: <T extends ValueShape>(
|
|
596
|
+
shape: T,
|
|
597
|
+
): ArrayValueShape<T> & WithNullable<ArrayValueShape<T>> => {
|
|
598
|
+
const base: ArrayValueShape<T> = {
|
|
599
|
+
_type: "value" as const,
|
|
600
|
+
valueType: "array" as const,
|
|
601
|
+
shape,
|
|
602
|
+
_plain: [] as any,
|
|
603
|
+
_mutable: [] as any,
|
|
604
|
+
_placeholder: [] as never[],
|
|
605
|
+
}
|
|
606
|
+
return Object.assign(base, {
|
|
607
|
+
nullable(): WithPlaceholder<
|
|
608
|
+
UnionValueShape<[NullValueShape, ArrayValueShape<T>]>
|
|
609
|
+
> {
|
|
610
|
+
return makeNullable(base)
|
|
611
|
+
},
|
|
612
|
+
})
|
|
613
|
+
},
|
|
513
614
|
|
|
514
615
|
// Special value type that helps make things like `string | null` representable
|
|
515
616
|
// TODO(duane): should this be a more general type for containers too?
|