@kaiko.io/rescript-deser 7.0.0 → 8.0.0-alpha.1

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.
@@ -4,7 +4,7 @@ module Appointment = {
4
4
  type t = {
5
5
  note: string,
6
6
  date: Date.t,
7
- extra: option<string>,
7
+ extra?: string,
8
8
  }
9
9
  module Deserializer = Deser.MakeDeserializer({
10
10
  type t = t
@@ -14,15 +14,42 @@ module Appointment = {
14
14
  })
15
15
  }
16
16
 
17
+ module List = {
18
+ type rec t = {
19
+ head: string,
20
+ tail?: t,
21
+ }
22
+ module Deserializer = Deser.MakeDeserializer({
23
+ type t = t
24
+ open Deser.Field
25
+ let fields = Object([("head", String), ("tail", Optional(Self))])
26
+ })
27
+ }
28
+
29
+ module Ledger = {
30
+ type rec t = {
31
+ records: List.t,
32
+ next?: t,
33
+ }
34
+ module Deserializer = Deser.MakeDeserializer({
35
+ type t = t
36
+ open Deser.Field
37
+ let fields = Object([
38
+ ("records", Deserializer(module(List.Deserializer))),
39
+ ("next", Optional(Self)),
40
+ ])
41
+ })
42
+ }
43
+
17
44
  module_("Basic deserializer", _ => {
18
45
  let valid: array<(_, Appointment.t)> = [
19
46
  (
20
47
  %raw(`{"note": "Bicentennial doctor's appointment", "date": "2100-01-01"}`),
21
48
  {
22
- note: "Bicentennial doctor's appointment",
23
- date: Date.fromString("2100-01-01"),
24
- extra: None,
25
- },
49
+ "note": "Bicentennial doctor's appointment",
50
+ "date": Date.fromString("2100-01-01"),
51
+ "extra": None, // Deser always return `undefined` for missing, optional fields
52
+ }->Obj.magic,
26
53
  ),
27
54
  (
28
55
  %raw(`{
@@ -33,7 +60,7 @@ module_("Basic deserializer", _ => {
33
60
  {
34
61
  note: "Bicentennial doctor's appointment",
35
62
  date: Date.fromString("2100-01-01"),
36
- extra: Some("Don't take your stop-aging pills the night before the appointment"),
63
+ extra: "Don't take your stop-aging pills the night before the appointment",
37
64
  },
38
65
  ),
39
66
  ]
@@ -131,15 +158,8 @@ module_("Recursive deserializer", _ => {
131
158
  })
132
159
 
133
160
  test("Finite list", qunit => {
134
- module List = Deser.MakeDeserializer({
135
- open Deser.Field
136
-
137
- type t
138
- let fields = Object([("head", String), ("tail", Optional(Self))])
139
- })
140
-
141
161
  qunit->expect(1)
142
- qunit->deepEqual(List.checkFieldsSanity(), Ok(), "Ok")
162
+ qunit->deepEqual(List.Deserializer.checkFieldsSanity(), Ok(), "Ok")
143
163
  })
144
164
 
145
165
  test("Correctly deserializes recursive data", qunit => {
@@ -156,34 +176,21 @@ module_("Recursive deserializer", _ => {
156
176
  })
157
177
 
158
178
  test("Recursion in sub-deserializer", qunit => {
159
- module List = Deser.MakeDeserializer({
160
- open Deser.Field
161
-
162
- type t
163
- let fields = Object([("head", String), ("tail", Optional(Self))])
164
- })
165
-
166
- module Ledger = Deser.MakeDeserializer({
167
- open Deser.Field
168
-
169
- type t
170
- let fields = Object([("records", Deserializer(module(List))), ("next", Optional(Self))])
171
- })
172
-
173
179
  let data = %raw(`
174
180
  {"records": {"head": "A", "tail": {"head": "B"}},
175
181
  "next": {"records": {"head": "A", "tail": {"head": "B"}}}}
176
182
  `)
177
- let expected = {
183
+ // Deser always returns `undefined` for missing optional fields
184
+ let expected: Ledger.t = {
178
185
  "records": {"head": "A", "tail": {"head": "B", "tail": None}},
179
186
  "next": {
180
187
  "records": {"head": "A", "tail": {"head": "B", "tail": None}},
181
188
  "next": None,
182
189
  },
183
- }
190
+ }->Obj.magic
184
191
 
185
192
  qunit->expect(1)
186
- qunit->deepEqual(data->Ledger.fromJSON->Obj.magic, Ok(expected), "nice ledger")
193
+ qunit->deepEqual(data->Ledger.Deserializer.fromJSON, Ok(expected), "nice ledger")
187
194
  })
188
195
  })
189
196
 
@@ -210,3 +217,141 @@ module_("Type safety limits", _ => {
210
217
  )
211
218
  })
212
219
  })
220
+
221
+ module_("isSafeCast and toJSON", _ => {
222
+ module SafePrimitives = {
223
+ type t = {
224
+ name: string,
225
+ count: int,
226
+ ratio: float,
227
+ active: bool,
228
+ }
229
+ module Deserializer = Deser.MakeDeserializer({
230
+ type t = t
231
+ open Deser.Field
232
+ let fields = Object([("name", String), ("count", Int), ("ratio", Float), ("active", Boolean)])
233
+ })
234
+ }
235
+
236
+ module SafeNested = {
237
+ type t = {
238
+ items: array<string>,
239
+ tags: dict<int>,
240
+ label?: string,
241
+ }
242
+ module Deserializer = Deser.MakeDeserializer({
243
+ type t = t
244
+ open Deser.Field
245
+ let fields = Object([
246
+ ("items", Array(String)),
247
+ ("tags", Mapping(Int)),
248
+ ("label", Optional(String)),
249
+ ])
250
+ })
251
+ }
252
+
253
+ module UnsafeWithDate = {
254
+ type t = {
255
+ name: string,
256
+ created: Date.t,
257
+ }
258
+ module Deserializer = Deser.MakeDeserializer({
259
+ type t = t
260
+ open Deser.Field
261
+ let fields = Object([("name", String), ("created", Date)])
262
+ })
263
+ }
264
+
265
+ module NestedUnsafe = {
266
+ type t = {event: UnsafeWithDate.t}
267
+ module Deserializer = Deser.MakeDeserializer({
268
+ type t = t
269
+ open Deser.Field
270
+ let fields = Object([("event", Deserializer(module(UnsafeWithDate.Deserializer)))])
271
+ })
272
+ }
273
+
274
+ test("Safe types: primitives return true for isSafeCast", qunit => {
275
+ qunit->expect(1)
276
+ qunit->isTrue(SafePrimitives.Deserializer.isSafeCast(), "primitives are safe")
277
+ })
278
+
279
+ test("Safe types: nested arrays/mappings/optionals return true for isSafeCast", qunit => {
280
+ qunit->expect(1)
281
+ qunit->isTrue(SafeNested.Deserializer.isSafeCast(), "nested safe types are safe")
282
+ })
283
+
284
+ test("Unsafe types: Date returns false for isSafeCast", qunit => {
285
+ qunit->expect(1)
286
+ qunit->isFalse(UnsafeWithDate.Deserializer.isSafeCast(), "Date is unsafe")
287
+ })
288
+
289
+ test("Unsafe types: nested deserializer with Date returns false", qunit => {
290
+ qunit->expect(1)
291
+ qunit->isFalse(NestedUnsafe.Deserializer.isSafeCast(), "nested unsafe is unsafe")
292
+ })
293
+
294
+ test("Round-trip for safe types", qunit => {
295
+ let json: JSON.t = %raw(`{"name": "test", "count": 42, "ratio": 3.14, "active": true}`)
296
+ switch SafePrimitives.Deserializer.fromJSON(json) {
297
+ | Ok(value) => {
298
+ let serialized = SafePrimitives.Deserializer.toJSON(value)
299
+ qunit->deepEqual(serialized, json, "round-trip preserves data")
300
+ }
301
+ | Error(msg) => qunit->isFalse(true, msg)
302
+ }
303
+ })
304
+
305
+ test("Round-trip for unsafe types with Date", qunit => {
306
+ let json: JSON.t = %raw(`{"name": "event", "created": "2024-01-15T10:30:00.000Z"}`)
307
+ switch UnsafeWithDate.Deserializer.fromJSON(json) {
308
+ | Ok(value) => {
309
+ let serialized = UnsafeWithDate.Deserializer.toJSON(value)
310
+ qunit->deepEqual(serialized, json, "round-trip preserves data with Date")
311
+ }
312
+ | Error(msg) => qunit->isFalse(true, msg)
313
+ }
314
+ })
315
+
316
+ test("Nested deserializer safety propagation", qunit => {
317
+ let json: JSON.t = %raw(`{"event": {"name": "meeting", "created": "2024-06-01T09:00:00.000Z"}}`)
318
+ switch NestedUnsafe.Deserializer.fromJSON(json) {
319
+ | Ok(value) => {
320
+ let serialized = NestedUnsafe.Deserializer.toJSON(value)
321
+ qunit->deepEqual(serialized, json, "nested unsafe round-trip works")
322
+ }
323
+ | Error(msg) => qunit->isFalse(true, msg)
324
+ }
325
+ })
326
+
327
+ test("Morphism of safe type is safe", qunit => {
328
+ module WithMorphism = Deser.MakeDeserializer({
329
+ type t = int
330
+ open Deser.Field
331
+ let fields = Morphism(Int, v => v)
332
+ })
333
+ qunit->expect(1)
334
+ qunit->isTrue(WithMorphism.isSafeCast(), "morphism of Int is safe")
335
+ })
336
+
337
+ test("List deserializer is safe (recursive with Self)", qunit => {
338
+ qunit->expect(1)
339
+ qunit->isTrue(List.Deserializer.isSafeCast(), "recursive list with strings is safe")
340
+ })
341
+
342
+ test("Appointment deserializer is unsafe (has Date)", qunit => {
343
+ qunit->expect(1)
344
+ qunit->isFalse(Appointment.Deserializer.isSafeCast(), "Appointment with Date is unsafe")
345
+ })
346
+
347
+ test("toJSON serializes Date to ISO string", qunit => {
348
+ let appointment: Appointment.t = {
349
+ note: "Test",
350
+ date: Date.fromString("2024-03-15T14:30:00.000Z"),
351
+ extra: "extra info",
352
+ }
353
+ let json = Appointment.Deserializer.toJSON(appointment)
354
+ let expected: JSON.t = %raw(`{"note": "Test", "date": "2024-03-15T14:30:00.000Z", "extra": "extra info"}`)
355
+ qunit->deepEqual(json, expected, "Date serialized to ISO string")
356
+ })
357
+ })
package/lib/rescript.lock CHANGED
@@ -1 +1 @@
1
- 1387245
1
+ 130543
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaiko.io/rescript-deser",
3
- "version": "7.0.0",
3
+ "version": "8.0.0-alpha.1",
4
4
  "keywords": [
5
5
  "json",
6
6
  "deserializer",
package/src/Deser.res CHANGED
@@ -25,7 +25,9 @@ module type Deserializer = {
25
25
  type t
26
26
  let name: string
27
27
  let fromJSON: JSON.t => result<t, string>
28
+ let toJSON: t => JSON.t
28
29
  let checkFieldsSanity: unit => result<unit, string>
30
+ let isSafeCast: unit => bool
29
31
  }
30
32
 
31
33
  module Field = {
@@ -305,6 +307,66 @@ module Field = {
305
307
  )
306
308
  ->Result.map(_ => ())
307
309
  }
310
+
311
+ let rec isSafeCast = (field: t): bool =>
312
+ switch field {
313
+ // Any is considered *safe* because it SHOULD only be used if the type you're getting from
314
+ // JSON.t is compatible with the internal representation anyways.
315
+ | Any => true
316
+ | String | Literal(_) | Int | Float | Boolean | Self => true
317
+ | Date | Datetime => false
318
+ | Morphism(inner, _) => isSafeCast(inner)
319
+ | Array(inner)
320
+ | Optional(inner)
321
+ | OptionalWithDefault(inner, _)
322
+ | Mapping(inner)
323
+ | DefaultWhenInvalid(inner, _) =>
324
+ isSafeCast(inner)
325
+ | Tuple(fields) => fields->Array.every(isSafeCast)
326
+ | Object(fields) => fields->Array.every(((_, f)) => isSafeCast(f))
327
+ | Deserializer(mod) | Collection(mod) => {
328
+ module M = unpack(mod: Deserializer)
329
+ M.isSafeCast()
330
+ }
331
+ }
332
+
333
+ let rec serialize: ('a, t, t) => JSON.t = (value, shape, self) =>
334
+ switch shape {
335
+ | Any | String | Int | Float | Boolean => value->Obj.magic
336
+ | Literal(v) => JSON.String(v)
337
+ | Self => serialize(value, self, self)
338
+ | Date | Datetime => JSON.String(value->Obj.magic->Date.toISOString)
339
+
340
+ | Morphism(inner, _) => serialize(value, inner, self)
341
+ | Array(inner) => JSON.Array(value->Obj.magic->Array.map(v => serialize(v, inner, self)))
342
+
343
+ | Tuple(fields) =>
344
+ JSON.Array(Belt.Array.zipBy(value->Obj.magic, fields, (v, f) => serialize(v, f, self)))
345
+
346
+ | Object(fields) =>
347
+ JSON.Object(
348
+ fields
349
+ ->Array.filterMap(((key, fieldType)) =>
350
+ value
351
+ ->Obj.magic
352
+ ->Dict.get(key)
353
+ ->Option.map(fieldValue => (key, serialize(fieldValue, fieldType, self)))
354
+ )
355
+ ->Dict.fromArray,
356
+ )
357
+ | Optional(inner) =>
358
+ switch value->Obj.magic {
359
+ | Some(v) => serialize(v, inner, self)
360
+ | None => JSON.Null
361
+ }
362
+ | OptionalWithDefault(inner, _) | DefaultWhenInvalid(inner, _) => serialize(value, inner, self)
363
+ | Mapping(inner) =>
364
+ JSON.Object(value->Obj.magic->Dict.mapValues(v => serialize(v, inner, self)))
365
+ | Deserializer(mod) | Collection(mod) => {
366
+ module M = unpack(mod: Deserializer)
367
+ M.toJSON(value->Obj.magic)
368
+ }
369
+ }
308
370
  }
309
371
 
310
372
  module type Serializable = {
@@ -315,10 +377,24 @@ module type Serializable = {
315
377
  module MakeDeserializer = (S: Serializable): (Deserializer with type t = S.t) => {
316
378
  type t = S.t
317
379
  let fields = S.fields
318
- %%private(let (loc, _f) = __LOC_OF__(module(S: Serializable)))
380
+ let (loc, _f) = __LOC_OF__(module(S: Serializable))
319
381
  let name = `Deserializer ${__MODULE__}, ${loc}`
320
382
 
321
- %%private(external _toNativeType: FieldValue.t => t = "%identity")
383
+ external _toNativeType: FieldValue.t => t = "%identity"
384
+ external _fromNativeType: t => FieldValue.t = "%identity"
385
+
386
+ let safeCastCache: ref<option<bool>> = ref(None)
387
+
388
+ let isSafeCast = () => {
389
+ switch safeCastCache.contents {
390
+ | Some(cached) => cached
391
+ | None => {
392
+ let result = Field.isSafeCast(fields)
393
+ safeCastCache := Some(result)
394
+ result
395
+ }
396
+ }
397
+ }
322
398
 
323
399
  @doc("Checks for trivial infinite-recursion in the fields of the module.
324
400
 
@@ -331,11 +407,20 @@ module MakeDeserializer = (S: Serializable): (Deserializer with type t = S.t) =>
331
407
  ")
332
408
  let checkFieldsSanity = () => Field.checkFieldsSanity(name, fields, false)
333
409
 
334
- @doc("Parse a `'a` into `result<t, string>`")
410
+ @doc("Parse a `JSON.t` into `result<t, string>`")
335
411
  let fromJSON: JSON.t => result<t, _> = json => {
336
412
  switch json->Field.fromUntagged(fields, fields) {
337
413
  | res => Ok(res->_toNativeType)
338
414
  | exception TypeError(e) => Error(e)
339
415
  }
340
416
  }
417
+
418
+ @doc("Serialize a `t` into `JSON.t`")
419
+ let toJSON: t => JSON.t = value => {
420
+ if isSafeCast() {
421
+ value->Obj.magic
422
+ } else {
423
+ Field.serialize(value, fields, fields)
424
+ }
425
+ }
341
426
  }
package/tests/index.res CHANGED
@@ -4,7 +4,7 @@ module Appointment = {
4
4
  type t = {
5
5
  note: string,
6
6
  date: Date.t,
7
- extra: option<string>,
7
+ extra?: string,
8
8
  }
9
9
  module Deserializer = Deser.MakeDeserializer({
10
10
  type t = t
@@ -14,15 +14,42 @@ module Appointment = {
14
14
  })
15
15
  }
16
16
 
17
+ module List = {
18
+ type rec t = {
19
+ head: string,
20
+ tail?: t,
21
+ }
22
+ module Deserializer = Deser.MakeDeserializer({
23
+ type t = t
24
+ open Deser.Field
25
+ let fields = Object([("head", String), ("tail", Optional(Self))])
26
+ })
27
+ }
28
+
29
+ module Ledger = {
30
+ type rec t = {
31
+ records: List.t,
32
+ next?: t,
33
+ }
34
+ module Deserializer = Deser.MakeDeserializer({
35
+ type t = t
36
+ open Deser.Field
37
+ let fields = Object([
38
+ ("records", Deserializer(module(List.Deserializer))),
39
+ ("next", Optional(Self)),
40
+ ])
41
+ })
42
+ }
43
+
17
44
  module_("Basic deserializer", _ => {
18
45
  let valid: array<(_, Appointment.t)> = [
19
46
  (
20
47
  %raw(`{"note": "Bicentennial doctor's appointment", "date": "2100-01-01"}`),
21
48
  {
22
- note: "Bicentennial doctor's appointment",
23
- date: Date.fromString("2100-01-01"),
24
- extra: None,
25
- },
49
+ "note": "Bicentennial doctor's appointment",
50
+ "date": Date.fromString("2100-01-01"),
51
+ "extra": None, // Deser always return `undefined` for missing, optional fields
52
+ }->Obj.magic,
26
53
  ),
27
54
  (
28
55
  %raw(`{
@@ -33,7 +60,7 @@ module_("Basic deserializer", _ => {
33
60
  {
34
61
  note: "Bicentennial doctor's appointment",
35
62
  date: Date.fromString("2100-01-01"),
36
- extra: Some("Don't take your stop-aging pills the night before the appointment"),
63
+ extra: "Don't take your stop-aging pills the night before the appointment",
37
64
  },
38
65
  ),
39
66
  ]
@@ -131,15 +158,8 @@ module_("Recursive deserializer", _ => {
131
158
  })
132
159
 
133
160
  test("Finite list", qunit => {
134
- module List = Deser.MakeDeserializer({
135
- open Deser.Field
136
-
137
- type t
138
- let fields = Object([("head", String), ("tail", Optional(Self))])
139
- })
140
-
141
161
  qunit->expect(1)
142
- qunit->deepEqual(List.checkFieldsSanity(), Ok(), "Ok")
162
+ qunit->deepEqual(List.Deserializer.checkFieldsSanity(), Ok(), "Ok")
143
163
  })
144
164
 
145
165
  test("Correctly deserializes recursive data", qunit => {
@@ -156,34 +176,21 @@ module_("Recursive deserializer", _ => {
156
176
  })
157
177
 
158
178
  test("Recursion in sub-deserializer", qunit => {
159
- module List = Deser.MakeDeserializer({
160
- open Deser.Field
161
-
162
- type t
163
- let fields = Object([("head", String), ("tail", Optional(Self))])
164
- })
165
-
166
- module Ledger = Deser.MakeDeserializer({
167
- open Deser.Field
168
-
169
- type t
170
- let fields = Object([("records", Deserializer(module(List))), ("next", Optional(Self))])
171
- })
172
-
173
179
  let data = %raw(`
174
180
  {"records": {"head": "A", "tail": {"head": "B"}},
175
181
  "next": {"records": {"head": "A", "tail": {"head": "B"}}}}
176
182
  `)
177
- let expected = {
183
+ // Deser always returns `undefined` for missing optional fields
184
+ let expected: Ledger.t = {
178
185
  "records": {"head": "A", "tail": {"head": "B", "tail": None}},
179
186
  "next": {
180
187
  "records": {"head": "A", "tail": {"head": "B", "tail": None}},
181
188
  "next": None,
182
189
  },
183
- }
190
+ }->Obj.magic
184
191
 
185
192
  qunit->expect(1)
186
- qunit->deepEqual(data->Ledger.fromJSON->Obj.magic, Ok(expected), "nice ledger")
193
+ qunit->deepEqual(data->Ledger.Deserializer.fromJSON, Ok(expected), "nice ledger")
187
194
  })
188
195
  })
189
196
 
@@ -210,3 +217,141 @@ module_("Type safety limits", _ => {
210
217
  )
211
218
  })
212
219
  })
220
+
221
+ module_("isSafeCast and toJSON", _ => {
222
+ module SafePrimitives = {
223
+ type t = {
224
+ name: string,
225
+ count: int,
226
+ ratio: float,
227
+ active: bool,
228
+ }
229
+ module Deserializer = Deser.MakeDeserializer({
230
+ type t = t
231
+ open Deser.Field
232
+ let fields = Object([("name", String), ("count", Int), ("ratio", Float), ("active", Boolean)])
233
+ })
234
+ }
235
+
236
+ module SafeNested = {
237
+ type t = {
238
+ items: array<string>,
239
+ tags: dict<int>,
240
+ label?: string,
241
+ }
242
+ module Deserializer = Deser.MakeDeserializer({
243
+ type t = t
244
+ open Deser.Field
245
+ let fields = Object([
246
+ ("items", Array(String)),
247
+ ("tags", Mapping(Int)),
248
+ ("label", Optional(String)),
249
+ ])
250
+ })
251
+ }
252
+
253
+ module UnsafeWithDate = {
254
+ type t = {
255
+ name: string,
256
+ created: Date.t,
257
+ }
258
+ module Deserializer = Deser.MakeDeserializer({
259
+ type t = t
260
+ open Deser.Field
261
+ let fields = Object([("name", String), ("created", Date)])
262
+ })
263
+ }
264
+
265
+ module NestedUnsafe = {
266
+ type t = {event: UnsafeWithDate.t}
267
+ module Deserializer = Deser.MakeDeserializer({
268
+ type t = t
269
+ open Deser.Field
270
+ let fields = Object([("event", Deserializer(module(UnsafeWithDate.Deserializer)))])
271
+ })
272
+ }
273
+
274
+ test("Safe types: primitives return true for isSafeCast", qunit => {
275
+ qunit->expect(1)
276
+ qunit->isTrue(SafePrimitives.Deserializer.isSafeCast(), "primitives are safe")
277
+ })
278
+
279
+ test("Safe types: nested arrays/mappings/optionals return true for isSafeCast", qunit => {
280
+ qunit->expect(1)
281
+ qunit->isTrue(SafeNested.Deserializer.isSafeCast(), "nested safe types are safe")
282
+ })
283
+
284
+ test("Unsafe types: Date returns false for isSafeCast", qunit => {
285
+ qunit->expect(1)
286
+ qunit->isFalse(UnsafeWithDate.Deserializer.isSafeCast(), "Date is unsafe")
287
+ })
288
+
289
+ test("Unsafe types: nested deserializer with Date returns false", qunit => {
290
+ qunit->expect(1)
291
+ qunit->isFalse(NestedUnsafe.Deserializer.isSafeCast(), "nested unsafe is unsafe")
292
+ })
293
+
294
+ test("Round-trip for safe types", qunit => {
295
+ let json: JSON.t = %raw(`{"name": "test", "count": 42, "ratio": 3.14, "active": true}`)
296
+ switch SafePrimitives.Deserializer.fromJSON(json) {
297
+ | Ok(value) => {
298
+ let serialized = SafePrimitives.Deserializer.toJSON(value)
299
+ qunit->deepEqual(serialized, json, "round-trip preserves data")
300
+ }
301
+ | Error(msg) => qunit->isFalse(true, msg)
302
+ }
303
+ })
304
+
305
+ test("Round-trip for unsafe types with Date", qunit => {
306
+ let json: JSON.t = %raw(`{"name": "event", "created": "2024-01-15T10:30:00.000Z"}`)
307
+ switch UnsafeWithDate.Deserializer.fromJSON(json) {
308
+ | Ok(value) => {
309
+ let serialized = UnsafeWithDate.Deserializer.toJSON(value)
310
+ qunit->deepEqual(serialized, json, "round-trip preserves data with Date")
311
+ }
312
+ | Error(msg) => qunit->isFalse(true, msg)
313
+ }
314
+ })
315
+
316
+ test("Nested deserializer safety propagation", qunit => {
317
+ let json: JSON.t = %raw(`{"event": {"name": "meeting", "created": "2024-06-01T09:00:00.000Z"}}`)
318
+ switch NestedUnsafe.Deserializer.fromJSON(json) {
319
+ | Ok(value) => {
320
+ let serialized = NestedUnsafe.Deserializer.toJSON(value)
321
+ qunit->deepEqual(serialized, json, "nested unsafe round-trip works")
322
+ }
323
+ | Error(msg) => qunit->isFalse(true, msg)
324
+ }
325
+ })
326
+
327
+ test("Morphism of safe type is safe", qunit => {
328
+ module WithMorphism = Deser.MakeDeserializer({
329
+ type t = int
330
+ open Deser.Field
331
+ let fields = Morphism(Int, v => v)
332
+ })
333
+ qunit->expect(1)
334
+ qunit->isTrue(WithMorphism.isSafeCast(), "morphism of Int is safe")
335
+ })
336
+
337
+ test("List deserializer is safe (recursive with Self)", qunit => {
338
+ qunit->expect(1)
339
+ qunit->isTrue(List.Deserializer.isSafeCast(), "recursive list with strings is safe")
340
+ })
341
+
342
+ test("Appointment deserializer is unsafe (has Date)", qunit => {
343
+ qunit->expect(1)
344
+ qunit->isFalse(Appointment.Deserializer.isSafeCast(), "Appointment with Date is unsafe")
345
+ })
346
+
347
+ test("toJSON serializes Date to ISO string", qunit => {
348
+ let appointment: Appointment.t = {
349
+ note: "Test",
350
+ date: Date.fromString("2024-03-15T14:30:00.000Z"),
351
+ extra: "extra info",
352
+ }
353
+ let json = Appointment.Deserializer.toJSON(appointment)
354
+ let expected: JSON.t = %raw(`{"note": "Test", "date": "2024-03-15T14:30:00.000Z", "extra": "extra info"}`)
355
+ qunit->deepEqual(json, expected, "Date serialized to ISO string")
356
+ })
357
+ })