Literal Ruby Gem
repository·main·Indexed 43 days ago
https://github.com/yippee-fun/literalA Ruby Gem for defining value types, enums, and data structures with runtime type enforcement. It provides tools for creating typed collections (Array, Set, Hash, Tuple), handling operation results via Result, Success, and Failure types, and implementing custom value transformations using Literal::Coercion.
What's inside Literal
- For comprehensive guides, API references, and detailed usage instructions, visit the official documentation website at https://literal.fun/docs/.
Understand the Literal runtime type system
mainLiteral is a runtime type system for Ruby where a type is defined as any object responding to
===(e.g.,String,5..10,/re/, a class, or aLiteraltype object). This allows types to be used directly incase/when,case/in, andgrep.Violations of these types raise
Literal::TypeError. WhileLiteral.check(v, t)andLiteral.subtype?(t, super)are available, they are considered internal and should generally not be used in application code.To use the bare keywords
undefined,never, andvoid, you mustrequire "literal/kernel".Serialization behavior for specific data types
mainWhen using
Literal::SerializationContext, note the following serialization rules:- DataStructures: Treated as closed objects via
propsandfrom_props. - Enums: The backing value is used.
- _Optional props: These are omitted during write operations but are restored to
Undefinedon read (they are never defaulted during deserialization). - Untagged Unions: Must be 'natural', meaning members are distinguishable by their JSON type or object shape.
- _TaggedUnion: Writes a
"$type"discriminator. This is merged into object members if possible; otherwise, it produces{"$type":, "value":}. - Hashes: Hashes with non-string keys are serialized as arrays of pairs.
- Schema Metadata: The
description:key is used to populate the JSON Schemadescriptionfield.
- DataStructures: Treated as closed objects via
Configure Coercion and Sealing pipelines
mainYou can compose pipelines for property blocks using
>>or<<.Literal::Coercion { |v| }: Normalizes input. Runs only at input boundaries (initializer, writers, draft assignment). It runsinstance_exec'd against the object under construction.Literal::Seal { |v| }: Fixes the final representation (e.g., freezing). Runs on every constructing store but not onmarshal_load. Must be idempotent and type-preserving.
Composition Rules:
coercion >> sealcreates a Seal that carries the coercion.seal >> coercionwill raise an error (coercion cannot run after sealing).
Built-in Coercions/Seals
Immutable: Seals by performing a shallowdupandfreeze.DeepImmutable: Seals by performing aRactor.make_shareablecopy.NilIfEmpty: Coerces empty strings, arrays, or hashes ("",[],{}) tonil.
Example of composing a generic type with a seal:
# An array of strings that is also immutable _Array(String) >> ImmutableUnderstand the error reporting structure
mainWhen validation fails, Literal provides structured error information via
Literal::Checks::ErrorsandLiteral::CheckError.Error Components:
prop: The property name being flagged (aSymbolor the key used for an unknown field).message: A human-readable description of the failure.path: A full route to the error (e.g., an array representing nested properties or array indices), allowing you to traverse the error tree.
Error Types:
Literal::TypeError: Raised immediately when a value does not match the declared type. Customcheckblocks do not run if a type error is present.Literal::CheckError: Raised when custom invariants fail. It carries theshapeand the collection oferrorsfound.
Use Literal::Draft for building objects
mainLiteral::Draft(P)provides a mutable builder that mirrors aPropertiesclass. In a draft, every property is optional (defaulting toLiteral::Undefined) and types are relaxed to_DraftState(T).Finalizing a Draft
Use
.finalizeto convert a draft into a real instance. You can provide overrides and a block for final touches.# Using a draft to build an instance instance = P.build(a: "val") do |draft| # last touches end # Or via explicit finalize draft = P.draft draft.a = "something" instance = draft.finalize(b: "override") ```n ### Key Behaviors - **`P.from_props(hash)`**: The inverse of `to_h`. It creates an instance from final values. It skips `initialize`/`after_initialize` and uses defaults for missing props. It does **not** run coercions. - **`P.from(other)`**: Rehydrates an instance from another object (e.g., a slice) by calling `from_props(other.to_h.slice(*own_props))`. - **Draft Types**: Draft classes are types that match any draft of a subtype.Use `Literal::Draft` for soft validation of untrusted input
mainWhen dealing with untrusted input (like API arguments or JSON) where you want to collect all errors (type errors, unknown keys, and custom checks) without immediately raising an exception, use
Literal::Draft.Usage Patterns:
- Validating known-good values: If you already have values that pass type checks but want to verify custom invariants, use
Literal::Draft(Shape).new(...). This will raise if types are wrong but hold check failures in abeyance. - Validating untrusted Hashes: If you have a raw Hash (with string or symbol keys), use
Literal::Draft(Shape).check(hash). This is the "soft path" that collects everything: type errors, unknown keys, and customcheckfailures.
Results:
Both methods return a
Literal::Result(Shape, Literal::Checks::Errors). You can then checkresult.sound?to see if the input was valid.Note: A draft never mutates the input and never constructs an invalid object; it only builds the object once all checks pass.
# Validating a raw hash without raising result = Literal::Draft(Span).check({"min" => 1, "max" => 0}) if result.sound? span = result.value else puts result.errors end- Validating known-good values: If you already have values that pass type checks but want to verify custom invariants, use
Define data structures using Literal::Properties
mainYou can define structured objects by extending
Literal::Properties. This generates aninitializemethod with a kind-aware signature,to_h, and per-property methods. The storage pipeline follows: coerce → seal → check → assign.Property Syntax
prop :name, Type: Required keyword argument.prop :name, Type, :positional: Positional argument.prop? :name, Type: Optional property (typed as_Union(Type, Literal::Undefined)).prop :name, Type, default: :val: Default value (frozen object orProcexecuted viainstance_exec).prop :name, Type, &block: Block used for coercion and pre-checking.
Options
reader:,writer:,predicate:: Accept:public,:protected, or:private(defaults to:public).predicate: Generates aname?method (returnsfalseif the property isUndefined).description:: String used for JSON Schema generation.
Inheritance
Redefined properties in subclasses must maintain the same kind, have $\ge$ visibility, and can only narrow the type (LSP). No new properties can be added after a subclass has inherited from a parent.
Slicing
Use
.sliceto create an anonymous class containing a subset of properties, and.from(instance)to project an existing instance into that slice.class P < Literal::Object prop :a, String prop :b, _Integer(0..), reader: :public prop :c, Integer, :positional prop :d, Symbol, default: :x prop? :e, String prop :f, _Array(String), &Literal::Array(String) end # Slicing example PName = P.slice(:a) new_slice = PName.from(p_instance)class P < Literal::Object prop :a, String # required keyword prop :b, _Integer(0..), reader: :public prop :c, Integer, :positional # kinds: :positional :* :keyword(default) :** :& prop :d, Symbol, default: :x # default: frozen object or Proc (instance_exec'd) prop? :e, String # optional → _Union(T, Literal::Undefined) prop :f, _Array(String), &Literal::Array(String) # block = coercion, pre-check end # Slices PName = P.slice(:a) # anonymous class "P.slice(:a)" PName.from(p) # copies matching prop valuesIntegrate Literal with Rails
mainLiteral supports automatic loading within Rails environments. It provides several integrations for Rails-specific components:
- ActiveModel / ActiveRecord: Use the
:literal_enumand:literal_flagsattribute types to define attributes. - ActiveJob: Provides an enum serializer for use in background jobs.
- ActiveRecord Relations: Supports
ActiveRecord.Relation(Model)as a valid relation type.
# Example ActiveRecord attribute definition class User < ApplicationRecord attribute :color, :literal_enum, type: Color end- ActiveModel / ActiveRecord: Use the
What is a Literal::Array?
mainA
Literal::Arrayis a typed array that enforces its element type on every mutation. Unlike a standard RubyArray, the validity of its elements is guaranteed for the lifetime of the object.Key characteristics:
- Type Enforcement: The element type is fixed at construction. It cannot be changed in place. Methods like
narrowandwidenreturn new instances rather than modifying the existing one. - Distinct from Ruby Array: It is not a subtype of
Array. While you can pass plain arrays toLiteral::Arrayvianeworcoerce, they are type-checked upon entry. To get a plain array back, useto_aorto_ary, which return detached copies. - Safe Lookups: Methods that return an element (like
[]) returnLiteral::Undefinedinstead ofnilwhen an index is out of bounds or an element is not found.nilis only returned if the element type explicitly permits it. - Immutability vs Mutation: While the type is fixed, the array itself can be mutated (e.g., via
<<,push,pop) as long as the new elements match the established type.
- Type Enforcement: The element type is fixed at construction. It cannot be changed in place. Methods like
Understand Literal::Property and its roles
mainLiteral::Propertyis a core abstraction used by the Literal gem to describe individual data fields (properties) within a class. It encapsulates metadata about a field, including its name, type, kind (e.g., positional, keyword, splat), visibility, and whether it is optional or required.Properties are used to generate type-safe Ruby code, including:
- Readers: Accessor methods.
- Writers: Mutator methods with type checking and coercion.
- Predicates: Boolean methods (e.g.,
name?) that handleLiteral::Undefinedcorrectly. - Initializers: Logic to handle parameter assignment, defaults, coercion, and sealing during object instantiation.
Key concepts:
- Kinds: Properties can be
:positional,:keyword,:*(splat),:**(double splat), or:&(block). - Optionality: A property is
optional?if it has a default value, aniltype, or isundefinable?(part of a Union type containingLiteral::Undefined). - Coercion & Sealing: Properties can define a
coercionproc to transform values or asealproc to validate them during assignment.
Understand the serializer dispatch mechanism
mainThe
Literalserialization walk useshandles_type?(type)to decide if a serializer is responsible for a node.Key behaviors:
- Shallow Dispatch:
handles_type?must perform a structural check only. It must not recurse into child types; the recursion is managed by thecontext's serializability walk. - Const Types: A serializer can handle a 'const type' (a literal value or a union of literal values) by checking if the type matches its domain via
const_type?. - Subtype Matching: It uses
Literal.subtype?(type, self.type)to check for compatibility.
- Shallow Dispatch: