commits
Prevent implicit success wrapping from block return values so Result control flow is always explicit via success/failure throws.
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.
Adds some comments to `#marshal_load` and `#marshal_dump` to clarify
their purpose.
Closes #348
---------
Co-authored-by: Ali Hamdi Ali Fadel <aliosm1997@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Updated `Literal.Result`’s block handling to assume success for returned values. Calling `success` or `failure` on the emitter throws.
This prevents you haven’t to remember to use `next` instead of `return` whern using the block.
This lets you look up which type in the union matched the value.
Fixes "Literal::TypeError::Context does not define to_h"
or alternatively
```
{
receiver:, method:, label:, expected:, actual:, children: children.map(&:to_h)
}
```
maybe reads nicer but obv uses the ivar accessor methods which the rest
of the class doesn't
Introspection for properties and splat properties always 'default'
This changes the behavior of `to_s` and `to_sym` on Enums.
`to_s` returns a humanized version of the demodularized class name, eg:
"Red," "Slate gray," "Spring green"
`to_sym` converts the demodularized class name to a symbol, eg: `:Red`,
`:SlateGray`, `:SPRING_GREEN`
Note that this was deemed a minor breaking change.
---------
Co-authored-by: Joel Drapper <joel@drapper.me>
Co-authored-by: Alexey Zapparov <alexey@zapparov.com>
Proposal:
It would be nice to have a `define` like the [native Data
structure](https://docs.ruby-lang.org/en/master/Data.html). It is useful
for code like this:
```ruby
# Before
class CreateUser
Success = Class.new(Literal::Data) do
prop :user, User
end
Failure = Class.new(Literal::Data) do
prop :reason, Symbol
prop :user, User
end
end
# After
class CreateUser
Success = Literal::Data.define(user: User)
Failure = Literal::Data.define(reason: Symbol, user: User)
end
```
With this define, we lose the ability to specify kind, reader,
predicate, and default values, but I believe the goal is to provide a
straightforward way to define simple data structures.
Co-authored-by: Joel Drapper <joel@drapper.me>
Proposal:
[Native Data
structure](https://docs.ruby-lang.org/en/master/Data.html#method-c-new)
allows objects to be instantied using `[]`, eg.
```ruby
Measure = Data.define(:amount, :unit)
Measure.new(amount: 1, unit: 'km')
#=> #<data Measure amount=1, unit="km">
Measure[amount: 1, unit: 'km']
#=> #<data Measure amount=1, unit="km">
```
I think Literal::Data should allow this syntax too to have an DX closer
to native Data structure. It was the first way I tried to instance a
Literal::Data and I got an error `undefined method '[]' for class Xyz`.
Add type signatures to the LSP auto-complete and hover cards.
<img width="790" alt="Screenshot 2025-06-06 at 12 10 52"
src="https://github.com/user-attachments/assets/fe2eaf50-6635-48da-b148-732ecc856f66"
/>
Set up basic Ruby LSP add-on. This add-on defines instance variables
when you use the `prop` macro. It also sets up reader and writer methods
with the correct visibility, but only when you specify them directly in
the macro.
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.
This changes the behavior of `to_s` and `to_sym` on Enums.
`to_s` returns a humanized version of the demodularized class name, eg:
"Red," "Slate gray," "Spring green"
`to_sym` converts the demodularized class name to a symbol, eg: `:Red`,
`:SlateGray`, `:SPRING_GREEN`
Note that this was deemed a minor breaking change.
---------
Co-authored-by: Joel Drapper <joel@drapper.me>
Co-authored-by: Alexey Zapparov <alexey@zapparov.com>
Proposal:
It would be nice to have a `define` like the [native Data
structure](https://docs.ruby-lang.org/en/master/Data.html). It is useful
for code like this:
```ruby
# Before
class CreateUser
Success = Class.new(Literal::Data) do
prop :user, User
end
Failure = Class.new(Literal::Data) do
prop :reason, Symbol
prop :user, User
end
end
# After
class CreateUser
Success = Literal::Data.define(user: User)
Failure = Literal::Data.define(reason: Symbol, user: User)
end
```
With this define, we lose the ability to specify kind, reader,
predicate, and default values, but I believe the goal is to provide a
straightforward way to define simple data structures.
Co-authored-by: Joel Drapper <joel@drapper.me>
Proposal:
[Native Data
structure](https://docs.ruby-lang.org/en/master/Data.html#method-c-new)
allows objects to be instantied using `[]`, eg.
```ruby
Measure = Data.define(:amount, :unit)
Measure.new(amount: 1, unit: 'km')
#=> #<data Measure amount=1, unit="km">
Measure[amount: 1, unit: 'km']
#=> #<data Measure amount=1, unit="km">
```
I think Literal::Data should allow this syntax too to have an DX closer
to native Data structure. It was the first way I tried to instance a
Literal::Data and I got an error `undefined method '[]' for class Xyz`.