SafeOstruct

A lightweight, dependency-free OpenStruct alternative backed by a plain symbol-keyed Hash. Published as the safe_ostruct gem.

SafeOstruct gives you the ergonomics of OpenStruct (dynamic attributes, method-style access) without its cost: it never defines singleton methods, so instantiation stays fast and Ruby's method caches stay intact. 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" # `require "safe_ostruct"` works too

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
obj.to_h      # => { 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

Behavior notes

These behaviors are intentional and covered by the test suite. They will not change without a major version bump.

  • 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.
  • respond_to? only reflects currently set attributes. obj.respond_to?(:missing) is false even though obj.missing returns nil, and respond_to?(:attr=) is false even though assignment works. Duck-type checks via respond_to? will only see attributes that have been set.
  • to_h returns the live internal hash, not a copy. Mutating the returned hash mutates the struct. Call obj.to_h.dup if you need an independent copy.

Performance

SafeOstruct exists for a specific trade: it is dramatically cheaper than OpenStruct to create, at the cost of slower per-attribute access (every read and write goes through method_missing). For the common lifecycle of value objects (create once, read a handful of attributes), creation cost dominates and SafeOstruct wins comfortably. If you create an object once and read the same attribute millions of times, use Struct or Data instead.

Results from benchmark/ips.rb (Ruby 3.2.2, x86_64-linux, higher is better):

Operation Hash Struct SafeOstruct OpenStruct
Instantiation (3 attributes) 13.9M i/s 10.5M i/s 3.2M i/s 0.16M i/s
Defined attribute read 61.1M i/s 57.7M i/s 9.2M i/s 27.9M i/s
Undefined attribute read n/a n/a 9.2M i/s 7.3M i/s
Attribute write 41.4M i/s n/a 4.8M i/s 21.5M i/s

Highlights:

  • Instantiation is ~20x faster than OpenStruct. OpenStruct defines singleton methods per instance, which is slow and invalidates method caches; SafeOstruct just stores a hash.
  • Reads of defined attributes are ~3x slower than OpenStruct (9.2M vs 27.9M i/s), because OpenStruct's lazily defined accessor is a real method while SafeOstruct always dispatches through method_missing.
  • Direct Hash or Struct access is roughly 6x faster than SafeOstruct. When attribute names are fixed and known up front, prefer those.

Run the benchmark yourself:

bundle exec ruby benchmark/ips.rb

Development

bundle install
bundle exec rake   # runs rspec + rubocop

License

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