0.0
The project is in a healthy, maintained state
SafeOstruct is a fast, dependency-free struct-like class with an OpenStruct-style interface. Instances are built on cached per-key-set shape classes with real attr_accessor methods, so attribute reads and writes run at Struct speed (within ~10% of raw Hash access) instead of going through method_missing. It supports dynamic attributes, method-style and hash-style (symbol or string) access, and returns nil instead of raising NoMethodError for undefined attributes.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

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

Releasing

Bump SafeOstruct::VERSION, add a CHANGELOG section, then tag and push:

git tag v2.1.0 && git push origin v2.1.0

The Release workflow verifies the tag matches the gem version, runs the test suite, builds the gem, and creates a GitHub Release with the CHANGELOG section as notes and the built .gem attached. If the RUBYGEMS_API_KEY repository secret is configured, it also publishes to rubygems.org (the API key must be allowed to push under your rubygems.org MFA settings).

License

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