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.anythingreturnsnilrather than raisingNoMethodError. 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)isfalseeven thoughobj.missingreturnsnil, andrespond_to?(:attr=)isfalseeven though assignment works. Duck-type checks viarespond_to?will only see attributes that have been set.to_hreturns the live internal hash, not a copy. Mutating the returned hash mutates the struct. Callobj.to_h.dupif 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
HashorStructaccess 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.