@kaiko.io/rescript-deser 7.0.0-alpha.2 → 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,163 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [Unreleased] - 8.0.0-alpha.1
6
+
7
+ ### Added
8
+
9
+ - **BREAKING**: `Deserializer` module type now requires `toJSON` and `isSafeCast`
10
+ - `toJSON` serializes values back to `JSON.t`
11
+ - `isSafeCast` returns true if the deserialized type has identical runtime representation
12
+ to `JSON.t` (allowing `Obj.magic` optimization in `toJSON`)
13
+
14
+ - `MakeDeserializer` implements `toJSON` and `isSafeCast` automatically:
15
+ - `isSafeCast` returns true for primitive types (`String`, `Int`, `Float`, `Boolean`, `Literal`)
16
+ - `isSafeCast` returns false for `Date`/`Datetime` (runtime representation differs from JSON string)
17
+ - When `isSafeCast()` is true, `toJSON` uses `Obj.magic` for zero-cost serialization
18
+ - When false, `toJSON` recursively serializes each field
19
+
20
+ **Caveats:**
21
+ - `Any` is considered safe, assuming the user only uses it when the ReScript type matches the
22
+ `JSON.t` representation. Misuse (e.g., declaring `Any` for a transformed type) will cause
23
+ `toJSON` to produce incorrect output. This matches the usage of `Any` in `fromJSON`.
24
+
25
+ - `Morphism(inner, fn)` only checks if `inner` is safe, ignoring the transformation. If `fn`
26
+ changes the runtime representation (e.g., `Morphism(String, parseInt)`), `toJSON` will serialize
27
+ the transformed value as if it were the original type, producing incorrect potentially incorrect
28
+ `JSON.t`.
29
+
30
+
31
+ ## [7.0.0] - 2025-12-19
32
+
33
+ ### Changed
34
+
35
+ - **BREAKING**: Migrate to ReScript 12
36
+ - **BREAKING**: Use the new `dict<_>` shortcut syntax
37
+
38
+ ## [7.0.0-alpha.2] - 2025-12-19
39
+
40
+ ### Changed
41
+
42
+ - CI: Use the frozen yarn.lock
43
+ - CI: Refactor CI pipeline
44
+
45
+ ## [7.0.0-alpha.1] - 2025-11-18
46
+
47
+ ### Changed
48
+
49
+ - Migrate from `Js.Json` to `RescriptCore.JSON` API
50
+ - Add automated test infrastructure for CLI and CI
51
+
52
+ ## [6.0.1] - 2025-06-01
53
+
54
+ ### Fixed
55
+
56
+ - Fix leaking the `Result.t` on Collections
57
+
58
+ ## [6.0.0] - 2025-06-01
59
+
60
+ ### Changed
61
+
62
+ - **BREAKING**: Rename module to `Deser` to avoid clash with RescriptCore's JSON
63
+ - Remove dependency on `rescript-prelude`
64
+
65
+ ## [5.0.1] - 2024-11-05
66
+
67
+ ### Changed
68
+
69
+ - Use 'esmodule' output format
70
+
71
+ ## [5.0.0] - 2024-11-05
72
+
73
+ ### Changed
74
+
75
+ - **BREAKING**: Require ReScript >= 11.1.0
76
+
77
+ ## [4.0.2] - 2024-04-27
78
+
79
+ ### Changed
80
+
81
+ - Allow to compile with ReScript 11.1
82
+
83
+ ## [4.0.1] - 2024-01-27
84
+
85
+ ### Changed
86
+
87
+ - Require compatible versions of prelude and rescript
88
+
89
+ ## [4.0.0] - 2024-01-11
90
+
91
+ ### Changed
92
+
93
+ - **BREAKING**: Upgrade to ReScript 11
94
+ - Switch to `rescript.json` configuration file
95
+ - Add explicit `@@uncurried` to the module
96
+
97
+ ## [3.3.0] - 2023-12-15
98
+
99
+ ### Changed
100
+
101
+ - Update to Prelude 6.5.0
102
+
103
+ ## [3.2.0] - 2023-10-16
104
+
105
+ ### Changed
106
+
107
+ - Update to Prelude 6.3.0
108
+
109
+ ## [3.1.3] - 2023-07-03
110
+
111
+ ### Fixed
112
+
113
+ - Add missing `bsconfig.json`
114
+
115
+ ## [3.1.2] - 2023-07-03
116
+
117
+ ### Changed
118
+
119
+ - Compile with both es6 and commonjs
120
+
121
+ ## [3.1.1] - 2023-07-03
122
+
123
+ ### Changed
124
+
125
+ - Distribute the compiled JS files
126
+
127
+ ## [3.1.0] - 2023-05-12
128
+
129
+ ### Added
130
+
131
+ - Add `Literal` field type
132
+
133
+ ## [3.0.0] - 2023-04-28
134
+
135
+ ### Changed
136
+
137
+ - **BREAKING**: Update to latest Prelude and ReScript
138
+
139
+ ## [2.1.0] - 2023-01-23
140
+
141
+ ### Added
142
+
143
+ - Add support for recursive structs with `Field.Self`
144
+ - Add trivial infinite recursion detection via `checkFieldsSanity`
145
+ - Add QUnit tests
146
+
147
+ ## [2.0.0] - 2022-12-07
148
+
149
+ ### Changed
150
+
151
+ - **BREAKING**: Switch to `MakeDeserializer` functor pattern
152
+
153
+ ## [1.0.1] - 2022-12-07
154
+
155
+ ### Changed
156
+
157
+ - Improve the README
158
+
159
+ ## [1.0.0] - 2022-12-07
160
+
161
+ ### Added
162
+
163
+ - Initial release as `@kaiko.io/rescript-deser`
@@ -1,2 +1,2 @@
1
- #Start(1766178477130)
2
- #Done(1766178477131)
1
+ #Start(1769849605835)
2
+ #Done(1769849605836)
@@ -4,5 +4,5 @@
4
4
  "bsc_hash": "a2b93197b8c05fc70981fe131a9ed75f8462a9f615f71055f306c9deb058d3cf",
5
5
  "rescript_config_hash": "6e13fe97e162573c9ab73327567294583b34d549775f8391e1d5c8ccc0c5d580",
6
6
  "runtime_path": "/home/manu/src/kaiko/rescript-deser/node_modules/@rescript/runtime",
7
- "generated_at": "1766178477132"
7
+ "generated_at": "1769849605837"
8
8
  }
Binary file
Binary file
Binary file
Binary file
@@ -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
  }
Binary file
Binary file
Binary file
Binary file
@@ -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
+ })