Runtime assertions for Ruby literal.fun
ruby
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Introduce literal serializers (#355)

This PR introduces flexible literal serialization contexts that allow
you to:

1. Build a graph of objects and types that are *serializable* in this
context
2. Serialize data against a type schema
3. Deserialize JSON data against the same type schema
4. Type check data to ensure it is serializable
5. Type check types to ensure the objects they describe are serializable

A `Literal::SerializationContext` is a set of rules about which objects
are serializable and how they should be serialized. It’s essentially a
collection of inter-connected serializers.

You can create a serialization context like this

```ruby
MySerializationContext = Literal::SerializationContext.new(
Literal::IntegerSerializer,
Literal::StringSerializer,
Literal::ArraySerializer
)
```

This context has a `type` which is the type of any object that can be
serialized in this context.

```ruby
MySerializationContext.type === [1] # true
MySerializationContext.type === ["hello"] # true
MySerializationContext.type === [1, -> { true }] # false
```

It also has a `kind` which is the type of any type that describes
serializable objects.

```ruby
MySerializationContext.kind === Integer # true, integers are serializable
MySerializationContext.kind === 1 # true, anything that matches the type 1 also matches the type Integer so 1 is serializable
MySerializationContext.kind === _Integer(1..) # true, anything that matches this type must be an integer too
MySerializationContext.kind === _Union(_Integer(100..), _Integer(..10)) # true, anything that matches this union must be an integer
MySerializationContext.kind === Array # false, arrays can contain non-serializable objects
MySerializationContext.kind === _Array(Integer) # true, arrays of integers *are* serializable
```

This kind is useful for validating types of types. For example, you
could define a class with literal properties but override the `prop`
method to only allow serializable types.

```ruby
def self.prop(name, type, *, **, &)
unless MySerializationContext.kind === type
raise ArgumentError, "The type #{type.inspect} is not a serializable type"
end

super
end
```

This would give you boot-time validation for these types.

You can serialize to JSON data using `serialize`, which takes a value
and a type.

```ruby
json_data = MySerializationContext.serialize([1], type: _Array(Integer))
```

The value must be described by the type and the type must be described
by the serialization context *kind*.

We will also validate that this serialization step returns valid
`_JSONData?`.

To deserialize the data, you can use the `deserialize` method, which
takes JSON data and a type.

```ruby
data = MySerializationContext.deserialize(json_data, type: _Array(Integer))
```

Because the serialization is based on the schema, we don’t typically
need to store any metadata in the serialized output.

Quick aside: what is `_JSONData?`? It’s a type that represents the union
of all JSON data types mapped to native Ruby primitives. Essentially all
the types you could get from parsing JSON. So it includes `String`,
`Integer`, `Float`, `Boolean`, `Array`, `Hash`, and `nil` but does not
include `Symbol`.

When we created the serialization context, we passed in a set of
serializers. These are special classes that are initialized by the
serialization context. Instances must respond to:

- `tag` returns a unique symbol that can be used to tag the type when
necessary (unions)
- `type` returns a type that describes the objects it can serialize
- `kind` returns a type that describes the types of objects it can
serialize (higher-order type)
- `serialize(value, type:)` returns the value serialized to JSON data
- `deserialize(raw, type:)` returns the raw JSON data deserialized back
to the type

Serializers are passed the serialization context at initialization. This
allows them to reference the serializable object and serializable type
kind from the context in their own types.

The `Literal::ArraySerializer` for example defines its type as an array
of any object that is serializable by the serialization context.

```ruby
def initialize(context)
@context = context
@type = _Array(@context.type)
@kind = _Kind(@type)
end
```

When it comes to serializating the arrays, the array serializer can
delegate serialization of nested objects to the context.

```ruby
def serialize(value, type:)
member_type = type.type

value.map do |item|
@context.serialize(item, type: member_type)
end
end

def deserialize(raw, type:)
member_type = type.type

raw.map do |item|
@context.deserialize(item, type: member_type)
end
end
```

Literal will offer a wide set of built in serializers, but you can also
make your own.

authored by

Joel Drapper and committed by
GitHub
f96df459 556addab

+734
+1
lib/literal.rb
··· 15 15 16 16 loader.collapse("#{__dir__}/literal/flags") 17 17 loader.collapse("#{__dir__}/literal/errors") 18 + loader.collapse("#{__dir__}/literal/serializers") 18 19 19 20 loader.setup 20 21 end
+75
lib/literal/serialization_context.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::SerializationContext 4 + include Literal::Types 5 + 6 + def initialize(*serializers) 7 + @type = _Deferred { @type } 8 + @kind = _Deferred { @kind } 9 + 10 + @serializers = serializers.map { |it| it.new(self) }.freeze 11 + 12 + @type = _Union(*@serializers.map(&:type)) 13 + @kind = _Union(*@serializers.map(&:kind)) 14 + 15 + @map = @serializers.to_h { |it| [it.tag, it] }.freeze 16 + 17 + freeze 18 + end 19 + 20 + attr_reader :serializers 21 + attr_reader :type 22 + attr_reader :kind 23 + 24 + def serialize(value, type:, strict: true) 25 + serializer = serializer_for_type(type) 26 + 27 + if strict && !(type === value) 28 + raise Literal::ArgumentError, "Value #{value.inspect} cannot be serialized as #{type.inspect}" 29 + end 30 + 31 + serialized = serializer.serialize(value, type:) 32 + 33 + if strict && !(_JSONData? === serialized) 34 + raise Literal::ArgumentError, "Value #{value.inspect} was not serialized correctly" 35 + end 36 + 37 + serialized 38 + end 39 + 40 + def deserialize(value, type:, strict: true) 41 + serializer = serializer_for_type(type) 42 + 43 + if strict && !(_JSONData === value) 44 + raise Literal::ArgumentError, "Value #{value.inspect} is not valid JSON data and cannot be deserialized as #{type.inspect}" 45 + end 46 + 47 + deserialized = serializer.deserialize(value, type:) 48 + 49 + if strict && !(type === deserialized) 50 + raise Literal::ArgumentError, "Value #{value.inspect} cannot be deserialized as #{type.inspect}" 51 + end 52 + 53 + deserialized 54 + end 55 + 56 + def serializer_for_type(type) 57 + if (serializer = @serializers.find { |it| it.kind === type }) 58 + serializer 59 + else 60 + raise Literal::ArgumentError, "No serializer type #{type.inspect}" 61 + end 62 + end 63 + 64 + def tag_for_type(type) 65 + serializer_for_type(type).tag 66 + end 67 + 68 + def serializer_for_tag(tag) 69 + if (serializer = @map[tag]) 70 + serializer 71 + else 72 + raise Literal::ArgumentError, "No serializer for tag #{tag.inspect}" 73 + end 74 + end 75 + end
+18
lib/literal/serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::Serializer 4 + extend Literal::Types 5 + include Literal::Types 6 + 7 + def initialize(context) 8 + @context = context 9 + end 10 + 11 + def serialize_contents(value, type:) 12 + @context.serialize(value, type:, strict: false) 13 + end 14 + 15 + def deserialize_contents(value, type:) 16 + @context.deserialize(value, type:, strict: false) 17 + end 18 + end
+34
lib/literal/serializers/array_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::ArraySerializer < Literal::Serializer 4 + Tag = :array 5 + 6 + def initialize(context) 7 + super 8 + @type = _Array(@context.type) 9 + @kind = _Kind(@type) 10 + end 11 + 12 + def tag 13 + Tag 14 + end 15 + 16 + attr_reader :type 17 + attr_reader :kind 18 + 19 + def serialize(value, type:) 20 + member_type = type.type 21 + 22 + value.map do |item| 23 + serialize_contents(item, type: member_type) 24 + end 25 + end 26 + 27 + def deserialize(raw, type:) 28 + member_type = type.type 29 + 30 + raw.map do |item| 31 + deserialize_contents(item, type: member_type) 32 + end 33 + end 34 + end
+27
lib/literal/serializers/boolean_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::BooleanSerializer < Literal::Serializer 4 + Tag = :boolean 5 + Type = _Boolean 6 + Kind = _Kind(Type) 7 + 8 + def tag 9 + Tag 10 + end 11 + 12 + def type 13 + Type 14 + end 15 + 16 + def kind 17 + Kind 18 + end 19 + 20 + def serialize(value, type:) 21 + value 22 + end 23 + 24 + def deserialize(raw, type:) 25 + raw 26 + end 27 + end
+27
lib/literal/serializers/date_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::DateSerializer < Literal::Serializer 4 + Tag = :date 5 + Type = Date 6 + Kind = _Kind(Type) 7 + 8 + def tag 9 + Tag 10 + end 11 + 12 + def type 13 + Type 14 + end 15 + 16 + def kind 17 + Kind 18 + end 19 + 20 + def serialize(value, type:) 21 + value.strftime("%Y-%m-%d") 22 + end 23 + 24 + def deserialize(raw, type:) 25 + Date.parse(raw) 26 + end 27 + end
+27
lib/literal/serializers/float_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::FloatSerializer < Literal::Serializer 4 + Tag = :float 5 + Type = Float 6 + Kind = _Kind(Type) 7 + 8 + def tag 9 + Tag 10 + end 11 + 12 + def type 13 + Type 14 + end 15 + 16 + def kind 17 + Kind 18 + end 19 + 20 + def serialize(value, type:) 21 + value 22 + end 23 + 24 + def deserialize(raw, type:) 25 + raw 26 + end 27 + end
+42
lib/literal/serializers/hash_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::HashSerializer < Literal::Serializer 4 + Tag = :hash 5 + 6 + def initialize(context) 7 + @context = context 8 + @type = _Hash(@context.type, @context.type) 9 + @kind = _Kind(@type) 10 + end 11 + 12 + def tag 13 + Tag 14 + end 15 + 16 + attr_reader :type 17 + attr_reader :kind 18 + 19 + def serialize(value, type:) 20 + key_type = type.key_type 21 + value_type = type.value_type 22 + 23 + value.to_h do |key, item| 24 + [ 25 + serialize_contents(key, type: key_type), 26 + serialize_contents(item, type: value_type), 27 + ] 28 + end 29 + end 30 + 31 + def deserialize(raw, type:) 32 + key_type = type.key_type 33 + value_type = type.value_type 34 + 35 + raw.to_h do |key, item| 36 + [ 37 + deserialize_contents(key, type: key_type), 38 + deserialize_contents(item, type: value_type), 39 + ] 40 + end 41 + end 42 + end
+27
lib/literal/serializers/integer_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::IntegerSerializer < Literal::Serializer 4 + Tag = :integer 5 + Type = Integer 6 + Kind = _Kind(Integer) 7 + 8 + def tag 9 + Tag 10 + end 11 + 12 + def type 13 + Type 14 + end 15 + 16 + def kind 17 + Kind 18 + end 19 + 20 + def serialize(value, type:) 21 + value 22 + end 23 + 24 + def deserialize(value, type:) 25 + value 26 + end 27 + end
+36
lib/literal/serializers/nilable_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::NilableSerializer < Literal::Serializer 4 + Tag = :nilable 5 + 6 + def initialize(context) 7 + @context = context 8 + @type = _Nilable(@context.type) 9 + @kind = _Kind(@type) 10 + end 11 + 12 + def tag 13 + Tag 14 + end 15 + 16 + attr_reader :type 17 + attr_reader :kind 18 + 19 + def serialize(value, type:) 20 + case value 21 + when nil 22 + nil 23 + else 24 + serialize_contents(value, type: type.type) 25 + end 26 + end 27 + 28 + def deserialize(raw, type:) 29 + case raw 30 + when nil 31 + nil 32 + else 33 + deserialize_contents(raw, type: type.type) 34 + end 35 + end 36 + end
+34
lib/literal/serializers/set_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::SetSerializer < Literal::Serializer 4 + Tag = :set 5 + 6 + def initialize(context) 7 + @context = context 8 + @type = _Set(@context.type) 9 + @kind = _Kind(@type) 10 + end 11 + 12 + def tag 13 + Tag 14 + end 15 + 16 + attr_reader :type 17 + attr_reader :kind 18 + 19 + def serialize(value, type:) 20 + member_type = type.type 21 + 22 + value.map do |item| 23 + serialize_contents(item, type: member_type) 24 + end 25 + end 26 + 27 + def deserialize(raw, type:) 28 + member_type = type.type 29 + 30 + raw.to_set do |item| 31 + deserialize_contents(item, type: member_type) 32 + end 33 + end 34 + end
+27
lib/literal/serializers/string_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::StringSerializer < Literal::Serializer 4 + Tag = :string 5 + Type = String 6 + Kind = _Kind(Type) 7 + 8 + def tag 9 + Tag 10 + end 11 + 12 + def type 13 + Type 14 + end 15 + 16 + def kind 17 + Kind 18 + end 19 + 20 + def serialize(value, type:) 21 + value 22 + end 23 + 24 + def deserialize(raw, type:) 25 + raw 26 + end 27 + end
+44
lib/literal/serializers/structure_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::StructureSerializer < Literal::Serializer 4 + Tag = :structure 5 + 6 + def initialize(context) 7 + @context = context 8 + 9 + @type = _Predicate("SerializableStructure") do |object| 10 + Literal::DataStructure === object && object.class.literal_properties.all? { |property| @context.kind === property.type } 11 + end 12 + 13 + @kind = _Predicate("SerializableStructureKind") do |type| 14 + Class === type && type < Literal::DataStructure && type.literal_properties.all? { |property| @context.kind === property.type } 15 + end 16 + end 17 + 18 + def tag 19 + Tag 20 + end 21 + 22 + attr_reader :type 23 + attr_reader :kind 24 + 25 + def serialize(value, type:) 26 + type.literal_properties.to_h do |property| 27 + [ 28 + property.name.to_s, 29 + serialize_contents(value.__send__(property.name), type: property.type), 30 + ] 31 + end 32 + end 33 + 34 + def deserialize(raw, type:) 35 + type.new( 36 + **type.literal_properties.to_h do |property| 37 + [ 38 + property.name, 39 + deserialize_contents(raw[property.name.to_s], type: property.type), 40 + ] 41 + end 42 + ) 43 + end 44 + end
+27
lib/literal/serializers/symbol_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::SymbolSerializer < Literal::Serializer 4 + Tag = :symbol 5 + Type = Symbol 6 + Kind = _Kind(Type) 7 + 8 + def tag 9 + Tag 10 + end 11 + 12 + def type 13 + Type 14 + end 15 + 16 + def kind 17 + Kind 18 + end 19 + 20 + def serialize(value, type:) 21 + value.name 22 + end 23 + 24 + def deserialize(raw, type:) 25 + raw.to_sym 26 + end 27 + end
+33
lib/literal/serializers/tagged_union_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::TaggedUnionSerializer < Literal::Serializer 4 + Tag = :tagged_union 5 + 6 + def initialize(context) 7 + @context = context 8 + @type = _Union(@context.type) 9 + @kind = _Predicate("SerializableTaggedUnion") do |type| 10 + Literal::Types::TaggedUnionType === type && type.members.each_value.all? { |member_type| @context.kind === member_type } 11 + end 12 + end 13 + 14 + def tag 15 + Tag 16 + end 17 + 18 + attr_reader :type 19 + attr_reader :kind 20 + 21 + def serialize(value, type:) 22 + tag, member_type = type.resolve(value) 23 + 24 + [tag.name, serialize_contents(value, type: member_type)] 25 + end 26 + 27 + def deserialize(raw, type:) 28 + tag, value = raw 29 + member_type = type[tag.to_sym] 30 + 31 + deserialize_contents(value, type: member_type) 32 + end 33 + end
+53
lib/literal/serializers/union_serializer.rb
··· 1 + # frozen_string_literal: true 2 + 3 + class Literal::UnionSerializer < Literal::Serializer 4 + Tag = :union 5 + 6 + def initialize(context) 7 + @context = context 8 + @type = _Deferred { @context.type } 9 + @kind = _Predicate("SerializableUnionKind") do |union| 10 + next false unless Literal::Types::UnionType === union 11 + 12 + tags = Set[] 13 + 14 + union.each.all? do |member| 15 + begin 16 + tag = @context.tag_for_type(member) 17 + rescue Literal::ArgumentError 18 + next false 19 + end 20 + 21 + next false if tags.include?(tag) 22 + 23 + tags << tag 24 + end 25 + end 26 + end 27 + 28 + def tag 29 + Tag 30 + end 31 + 32 + attr_reader :type 33 + attr_reader :kind 34 + 35 + def serialize(value, type:) 36 + member_type = type.resolve(value) 37 + tag = @context.tag_for_type(member_type) 38 + 39 + [tag.name, serialize_contents(value, type: member_type)] 40 + end 41 + 42 + def deserialize(raw_value, type:) 43 + tag_name, raw_member_value = raw_value 44 + serializer = @context.serializer_for_tag(tag_name.to_sym) 45 + member_type = type.each.find { |member| serializer.kind === member } 46 + 47 + unless member_type 48 + raise Literal::ArgumentError, "No union member type for tag #{tag_name.inspect} in #{type.inspect}" 49 + end 50 + 51 + deserialize_contents(raw_member_value, type: member_type) 52 + end 53 + end
+202
test/serialization.test.rb
··· 1 + # frozen_string_literal: true 2 + 3 + require "set" 4 + 5 + include Literal::Types 6 + 7 + class SerializationPerson < Literal::Data 8 + prop :name, String 9 + prop :age, Integer 10 + end 11 + 12 + class SerializationEnvelope < Literal::Data 13 + prop :id, Integer 14 + prop :owner, SerializationPerson 15 + prop :tags, _Set(Symbol) 16 + prop :metadata, _Hash(Symbol, _Array(_Nilable(_Union(String, Integer)))) 17 + prop :schedule, _Array(Date) 18 + prop :choice, _TaggedUnion(person: SerializationPerson, note: String) 19 + prop :payload, _Union(_Hash(Symbol, Integer), _Array(String)) 20 + end 21 + 22 + Example = Literal::SerializationContext.new( 23 + Literal::StringSerializer, 24 + Literal::SymbolSerializer, 25 + Literal::IntegerSerializer, 26 + Literal::FloatSerializer, 27 + Literal::BooleanSerializer, 28 + Literal::DateSerializer, 29 + Literal::StructureSerializer, 30 + Literal::TaggedUnionSerializer, 31 + Literal::UnionSerializer, 32 + Literal::HashSerializer, 33 + Literal::ArraySerializer, 34 + Literal::SetSerializer, 35 + Literal::NilableSerializer, 36 + ) 37 + 38 + test "array serialization roundtrip" do 39 + original = [1, 2, 3] 40 + type = _Array(Integer) 41 + serialized = Example.serialize([1, 2, 3], type:) 42 + 43 + assert_equal(serialized, [1, 2, 3]) 44 + assert_equal(Example.deserialize(serialized, type:), original) 45 + end 46 + 47 + test "integer serialization roundtrip" do 48 + original = 42 49 + type = Integer 50 + serialized = Example.serialize(42, type:) 51 + 52 + assert_equal(serialized, 42) 53 + assert_equal(Example.deserialize(serialized, type:), original) 54 + end 55 + 56 + test "string serialization roundtrip" do 57 + original = "example" 58 + type = String 59 + serialized = Example.serialize(original, type:) 60 + 61 + assert_equal(serialized, "example") 62 + assert_equal(Example.deserialize(serialized, type:), original) 63 + end 64 + 65 + test "symbol serialization roundtrip" do 66 + original = :example 67 + type = Symbol 68 + serialized = Example.serialize(:example, type:) 69 + 70 + assert_equal(serialized, "example") 71 + assert_equal(Example.deserialize(serialized, type:), original) 72 + end 73 + 74 + test "boolean serialization roundtrip" do 75 + original = true 76 + type = _Boolean 77 + serialized = Example.serialize(true, type:) 78 + 79 + assert_equal(serialized, true) 80 + assert_equal(Example.deserialize(serialized, type:), original) 81 + end 82 + 83 + test "date serialization roundtrip" do 84 + original = Date.new(2025, 1, 13) 85 + type = Date 86 + serialized = Example.serialize(original, type:) 87 + 88 + assert_equal(serialized, "2025-01-13") 89 + assert_equal(Example.deserialize(serialized, type:), original) 90 + end 91 + 92 + test "float serialization roundtrip" do 93 + original = 3.14 94 + type = Float 95 + serialized = Example.serialize(original, type:) 96 + 97 + assert_equal(serialized, 3.14) 98 + assert_equal(Example.deserialize(serialized, type:), original) 99 + end 100 + 101 + test "hash serialization roundtrip" do 102 + original = { foo: 1, bar: 2 } 103 + type = _Hash(Symbol, Integer) 104 + serialized = Example.serialize(original, type:) 105 + 106 + assert_equal(serialized, { "foo" => 1, "bar" => 2 }) 107 + assert_equal(Example.deserialize(serialized, type:), original) 108 + end 109 + 110 + test "nilable serialization roundtrip" do 111 + type = _Nilable(Integer) 112 + non_nil = 42 113 + non_nil_serialized = Example.serialize(non_nil, type:) 114 + nil_serialized = Example.serialize(nil, type:) 115 + 116 + assert_equal(non_nil_serialized, 42) 117 + assert_equal(Example.deserialize(non_nil_serialized, type:), non_nil) 118 + assert_equal(nil_serialized, nil) 119 + assert_equal(Example.deserialize(nil_serialized, type:), nil) 120 + end 121 + 122 + test "set serialization roundtrip" do 123 + original = Set[1, 2, 3] 124 + type = _Set(Integer) 125 + serialized = Example.serialize(original, type:) 126 + 127 + assert_equal(serialized, [1, 2, 3]) 128 + assert_equal(Example.deserialize(serialized, type:), original) 129 + end 130 + 131 + test "structure serialization roundtrip" do 132 + original = SerializationPerson.new(name: "Joel", age: 42) 133 + type = SerializationPerson 134 + serialized = Example.serialize(original, type:) 135 + 136 + assert_equal(serialized, { "name" => "Joel", "age" => 42 }) 137 + assert_equal(Example.deserialize(serialized, type:), original) 138 + end 139 + 140 + test "tagged union serialization roundtrip" do 141 + type = _TaggedUnion(name: String, age: Integer) 142 + name_original = "Joel" 143 + age_original = 42 144 + 145 + name_serialized = Example.serialize(name_original, type:) 146 + age_serialized = Example.serialize(age_original, type:) 147 + 148 + assert_equal(name_serialized, ["name", "Joel"]) 149 + assert_equal(age_serialized, ["age", 42]) 150 + assert_equal(Example.deserialize(name_serialized, type:), name_original) 151 + assert_equal(Example.deserialize(age_serialized, type:), age_original) 152 + end 153 + 154 + test "union serialization roundtrip" do 155 + type = _Union(String, Integer) 156 + name_original = "Joel" 157 + age_original = 42 158 + 159 + name_serialized = Example.serialize(name_original, type:) 160 + age_serialized = Example.serialize(age_original, type:) 161 + 162 + assert_equal(name_serialized, ["string", "Joel"]) 163 + assert_equal(age_serialized, ["integer", 42]) 164 + assert_equal(Example.deserialize(name_serialized, type:), name_original) 165 + assert_equal(Example.deserialize(age_serialized, type:), age_original) 166 + end 167 + 168 + test "big nested serialization roundtrip" do 169 + original = SerializationEnvelope.new( 170 + id: 7, 171 + owner: SerializationPerson.new(name: "Joel", age: 42), 172 + tags: Set[:admin, :staff], 173 + metadata: { 174 + primary: ["active", 1, nil], 175 + secondary: [2, "backup"], 176 + }, 177 + schedule: [Date.new(2025, 1, 13), Date.new(2025, 1, 20)], 178 + choice: SerializationPerson.new(name: "Jill", age: 40), 179 + payload: { count: 3, total: 9 }, 180 + ) 181 + 182 + type = SerializationEnvelope 183 + serialized = Example.serialize(original, type:) 184 + 185 + assert_equal(serialized, { 186 + "id" => 7, 187 + "owner" => { 188 + "name" => "Joel", 189 + "age" => 42, 190 + }, 191 + "tags" => ["admin", "staff"], 192 + "metadata" => { 193 + "primary" => [["string", "active"], ["integer", 1], nil], 194 + "secondary" => [["integer", 2], ["string", "backup"]], 195 + }, 196 + "schedule" => ["2025-01-13", "2025-01-20"], 197 + "choice" => ["person", { "name" => "Jill", "age" => 40 }], 198 + "payload" => ["hash", { "count" => 3, "total" => 9 }], 199 + }) 200 + 201 + assert_equal(Example.deserialize(serialized, type:), original) 202 + end