Literal Ruby Gem

repository·main·Indexed 43 days ago

https://github.com/yippee-fun/literal

A 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.

Tokens
13.7K
Snippets
48
Records
64
Agent score
53%

What's inside Literal

  1. Understand the Literal runtime type system

    main

    Literal 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 a Literal type object). This allows types to be used directly in case/when, case/in, and grep.

    Violations of these types raise Literal::TypeError. While Literal.check(v, t) and Literal.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, and void, you must require "literal/kernel".

  2. Serialization behavior for specific data types

    main

    When using Literal::SerializationContext, note the following serialization rules:

    • DataStructures: Treated as closed objects via props and from_props.
    • Enums: The backing value is used.
    • _Optional props: These are omitted during write operations but are restored to Undefined on 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 Schema description field.
  3. Configure Coercion and Sealing pipelines

    main

    You can compose pipelines for property blocks using >> or <<.

    • Literal::Coercion { |v| }: Normalizes input. Runs only at input boundaries (initializer, writers, draft assignment). It runs instance_exec'd against the object under construction.
    • Literal::Seal { |v| }: Fixes the final representation (e.g., freezing). Runs on every constructing store but not on marshal_load. Must be idempotent and type-preserving.

    Composition Rules:

    • coercion >> seal creates a Seal that carries the coercion.
    • seal >> coercion will raise an error (coercion cannot run after sealing).

    Built-in Coercions/Seals

    • Immutable: Seals by performing a shallow dup and freeze.
    • DeepImmutable: Seals by performing a Ractor.make_shareable copy.
    • NilIfEmpty: Coerces empty strings, arrays, or hashes ("", [], {}) to nil.

    Example of composing a generic type with a seal:

    # An array of strings that is also immutable
    _Array(String) >> Immutable
  4. Understand the error reporting structure

    main

    When validation fails, Literal provides structured error information via Literal::Checks::Errors and Literal::CheckError.

    Error Components:

    • prop: The property name being flagged (a Symbol or 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. Custom check blocks do not run if a type error is present.
    • Literal::CheckError: Raised when custom invariants fail. It carries the shape and the collection of errors found.
  5. Use Literal::Draft for building objects

    main

    Literal::Draft(P) provides a mutable builder that mirrors a Properties class. In a draft, every property is optional (defaulting to Literal::Undefined) and types are relaxed to _DraftState(T).

    Finalizing a Draft

    Use .finalize to 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.
    
  6. Use `Literal::Draft` for soft validation of untrusted input

    main

    When 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:

    1. 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.
    2. 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 custom check failures.

    Results:

    Both methods return a Literal::Result(Shape, Literal::Checks::Errors). You can then check result.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
  7. Define data structures using Literal::Properties

    main

    You can define structured objects by extending Literal::Properties. This generates an initialize method 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 or Proc executed via instance_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 a name? method (returns false if the property is Undefined).
    • 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 .slice to 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 values
  8. Integrate Literal with Rails

    main

    Literal supports automatic loading within Rails environments. It provides several integrations for Rails-specific components:

    • ActiveModel / ActiveRecord: Use the :literal_enum and :literal_flags attribute 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
  9. What is a Literal::Array?

    main

    A Literal::Array is a typed array that enforces its element type on every mutation. Unlike a standard Ruby Array, 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 narrow and widen return 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 to Literal::Array via new or coerce, they are type-checked upon entry. To get a plain array back, use to_a or to_ary, which return detached copies.
    • Safe Lookups: Methods that return an element (like []) return Literal::Undefined instead of nil when an index is out of bounds or an element is not found. nil is 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.
  10. Understand Literal::Property and its roles

    main

    Literal::Property is 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 handle Literal::Undefined correctly.
    • 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, a nil type, or is undefinable? (part of a Union type containing Literal::Undefined).
    • Coercion & Sealing: Properties can define a coercion proc to transform values or a seal proc to validate them during assignment.
  11. Understand the serializer dispatch mechanism

    main

    The Literal serialization walk uses handles_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 the context'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.