SafeOstruct

A fast, dependency-free OpenStruct alternative. Published as the safe_ostruct gem.

SafeOstruct gives you the ergonomics of OpenStruct (dynamic attributes, method-style access) at near-Hash speed. Attribute reads and writes are real methods on cached per-key-set "shape" classes, not method_missing dispatch, so they run at Struct speed (within ~10% of raw Hash access). Undefined attributes return nil instead of raising NoMethodError, which makes it a natural fit for API response objects, test doubles, and data with optional fields.

Installation

Add to your Gemfile:

gem "safe_ostruct"

Or install directly:

gem install safe_ostruct

Usage

require "safe_ostruct"

obj = SafeOstruct.new(name: "John Doe")

# Method-style and hash-style access, with symbols or strings
obj.name      # => "John Doe"
obj[:name]    # => "John Doe"
obj["name"]   # => "John Doe"

# Add attributes dynamically
obj.address = "123 Main St"
obj[:city] = "Springfield"

# Undefined attributes return nil, never NoMethodError
obj.age       # => nil
obj[:age]     # => nil

# Remove attributes
obj.delete_field(:address)
obj.address   # => nil

# Convert to a hash (a fresh copy, safe to mutate)
obj.to_h      # => { name: "John Doe", city: "Springfield" }

# OpenStruct API parity
obj == SafeOstruct.new(name: "John Doe", city: "Springfield")  # => true
obj.dig(:name)                                                 # => "John Doe"
obj.each_pair { |key, value| ... }
obj.inspect   # => #<SafeOstruct name="John Doe", city="Springfield">

It also works as a JSON.parse object class for dot-notation access to parsed JSON:

require "json"

parsed = JSON.parse('{"user": {"name": "Jane"}}', object_class: SafeOstruct)
parsed.user.name  # => "Jane"

And as a base class for simple result objects:

class ResponseResult < SafeOstruct
  def success?
    self[:status] == "success"
  end
end

How it works

SafeOstruct.new returns an instance of a cached subclass generated per unique key set (a "shape"). The shape class carries real attr_accessor methods backed by instance variables, so obj.name is an ordinary inline-cached method call instead of method_missing dispatch. Creating many structs with the same keys reuses one class, so the method cache stays intact (the classic OpenStruct problem). The shape cache is capped at SafeOstruct::SHAPE_CACHE_LIMIT (1000) classes per base class; beyond that, instances degrade gracefully to method_missing-based access, so arbitrary key sets (e.g. from JSON) cannot create classes unboundedly.

method_missing remains as a fallback for attributes added after creation and for undefined attributes, which always return nil.

Behavior notes

These behaviors are intentional and covered by the test suite.

  • Every undefined method returns nil. obj.anything returns nil rather than raising NoMethodError. This includes typos, so prefer SafeOstruct where absent-means-nil is the semantics you want, and avoid it where a typo should fail loudly.
  • to_h returns a fresh hash. Mutating the returned hash does not affect the struct.
  • respond_to? is true for shape attributes (getters and setters, even after delete_field), and true for dynamically added attributes while they hold a value. It stays false for attributes that were never set.
  • .class is an anonymous shape subclass. Use is_a?(SafeOstruct) (or your own base class) for type checks, not .class ==. Structs with equal attributes are == regardless of shape.
  • Keys that are not valid method names (e.g. "foo-bar") or that collide with existing methods (e.g. :to_h, :class) are stored and fully accessible hash-style (obj[:"foo-bar"]), just not via dot access. Keys starting with __ are reserved for internal storage.
  • Marshal is not supported because shape classes are anonymous. Serialize via obj.to_h instead.
  • Subclass instance variables appear in to_h. Attribute storage is instance variables, so a subclass that memoizes internal state into an ivar will see it in to_h; prefix internal ivars with __ to exclude them.

Performance

Attribute access runs at Struct speed because shape attributes are real attr_accessor methods. Results from benchmark/ips.rb (Ruby 3.2.2, x86_64-linux, YJIT disabled, higher is better):

Operation Hash Struct SafeOstruct OpenStruct
Defined attribute read 61.0M i/s 58.3M i/s 55.6M i/s 28.1M i/s
Attribute write 42.1M i/s n/a 53.0M i/s 20.0M i/s
Instantiation (3 attributes) 13.6M i/s 10.1M i/s 2.0M i/s 0.16M i/s
Undefined attribute read n/a n/a 6.2M i/s 7.2M i/s
Dynamically added attribute read n/a n/a 6.9M i/s n/a
Hash-style read ([]) 56.7M i/s n/a 20.0M i/s 28.9M i/s

Highlights:

  • Reads are within 10% of raw Hash access and effectively at Struct speed. Writes are faster than Hash#[]=.
  • Instantiation is ~12x faster than OpenStruct, which defines singleton methods per instance. It is slower than a bare Struct because of the shape-cache lookup; the cost is repaid after about two attribute accesses per object.
  • Attributes added after creation (and undefined reads) go through the method_missing fallback at ~6-7M i/s, comparable to OpenStruct's lazily defined accessors.
  • YJIT narrows the remaining gaps further.

Run the benchmark yourself:

bundle exec ruby benchmark/ips.rb

Migrating from 1.x

Version 2.0.0 changed the storage model from a single internal hash to shape classes. Interface-compatible, with these semantic differences:

  • to_h returns a fresh hash instead of the live internal hash; code that mutated the struct through to_h must use []= instead.
  • respond_to? now reflects shape accessors (true for initial attributes and their setters, even after delete_field).
  • .class is an anonymous shape subclass rather than SafeOstruct itself; is_a?(SafeOstruct) still holds.
  • Marshal.dump no longer works; use to_h.

Development

bundle install
bundle exec rake   # runs rspec + rubocop

License

The gem is available as open source under the terms of the MIT License.