ruby / ruby/psych

Custom domain types don't deserialize properly from anchors

Open
#786 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Ruby
Stars
597
Forks
223
Avg merge
11h 23m
Merged PRs (30d)
3

Description

I discovered this while trying to make YAML deserialization work with Sorbet enum classes, but this could happen with any custom domain type. I wrote a custom serializer using add_domain_type and a corresponding implementation of encode_with that I defined on T::Enum only to find that, if I serialize an object with the same enum value more than once, anything besides the first instance would actually just be a Hash with the properties I serialized on it. I have a simple example that repros the behavior:

require "yaml"
require "json"

::YAML.add_domain_type('test/custom_yaml', 'my_test_object') do |type, value|
  MyTestClass.new(prop1: value['prop1'], prop2: value['prop2'])
end

class MyTestClass
  attr_reader :prop1, :prop2

  def initialize(prop1:, prop2:)
    @prop1 = prop1
    @prop2 = prop2
  end

  def encode_with(coder)
    coder.tag = '!test/custom_yaml:my_test_object'

    coder.map = {
      'prop1' => @prop1,
      'prop2' => @prop2
    }
  end
end

test_obj = MyTestClass.new(prop1: 13, prop2: 1989)

generated = ::Psych.dump(
  {
    'object1' => test_obj,
    'object2' => test_obj,
    'object3' => test_obj
  }
)

puts "GENERATED YAML:"
puts generated
puts "END"

puts "PARSED YAML:"
puts ::Psych.safe_load(generated, permitted_classes: [MyTestClass], aliases: true)
puts "END"

Gives me the output:

GENERATED YAML:
---
object1: &1 !test/custom_yaml:my_test_object
  prop1: 13
  prop2: 1989
object2: *1
object3: *1
END
PARSED YAML:
{"object1" => #<MyTestClass:0x0000000120b4cfa0 @prop1=13, @prop2=1989>, "object2" => {"prop1" => 13, "prop2" => 1989}, "object3" => {"prop1" => 13, "prop2" => 1989}}
END

where the values of object2 and object3 in the resulting hash are actually hashes of the serialized properties, rather than an instance of MyTestClass

I believe the bug is that when objects are deserialized via domain types, the result isn't registered, such that subsequent alias references don't get the parsed version of the object. If I add in a register method inside of Psych::Visitors::ToRuby.accept, I get the same object for all 3 references in my example:

require "yaml"
require "json"

module Psych
  module Visitors
    class ToRuby
      def accept(target)
        result = super

        unless @domain_types.empty? || !target.tag
          key = target.tag.sub(/^[!\/]*/, '').sub(/(,\d+)\//, '\1:')
          key = "tag:#{key}" unless key.match?(/^(?:tag:|x-private)/)

          if @domain_types.key? key
            value, block = @domain_types[key]
            result = block.call value, result

            register(target, result)
          end
        end

        result = deduplicate(result).freeze if @freeze
        result
      end
    end
  end
end

::YAML.add_domain_type('test/custom_yaml', 'my_test_object') do |type, value|
  MyTestClass.new(prop1: value['prop1'], prop2: value['prop2'])
end

class MyTestClass
  attr_reader :prop1, :prop2

  def initialize(prop1:, prop2:)
    @prop1 = prop1
    @prop2 = prop2
  end

  def encode_with(coder)
    coder.tag = '!test/custom_yaml:my_test_object'

    coder.map = {
      'prop1' => @prop1,
      'prop2' => @prop2
    }
  end
end

test_obj = MyTestClass.new(prop1: 13, prop2: 1989)

generated = ::Psych.dump(
  {
    'object1' => test_obj,
    'object2' => test_obj,
    'object3' => test_obj
  }
)

puts "GENERATED YAML:"
puts generated
puts "END"

puts "PARSED YAML:"
puts ::Psych.safe_load(generated, permitted_classes: [MyTestClass], aliases: true)
puts "END"

Gives me the output:

GENERATED YAML:
---
object1: &1 !test/custom_yaml:my_test_object
  prop1: 13
  prop2: 1989
object2: *1
object3: *1
END
PARSED YAML:
{"object1" => #<MyTestClass:0x00000001015dc918 @prop1=13, @prop2=1989>, "object2" => #<MyTestClass:0x00000001015dc918 @prop1=13, @prop2=1989>, "object3" => #<MyTestClass:0x00000001015dc918 @prop1=13, @prop2=1989>}
END
Machine details

Apple M3 Macbook Air
Reproduced on: "ruby 3.4.9 (2026-03-11 revision 76cca827ab) +PRISM [arm64-darwin25]"

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Reproduce the provided Psych YAML example, then inspect Psych::Visitors::ToRuby#accept in lib/psych/visitors/to_ruby.rb, especially domain-type handling and alias registration. Done means repeated aliases for a custom domain type deserialize to the same custom object rather than property hashes.

Written by the indexing model from the issue text.

Assessment

Tech stack
ruby
Domain
data
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.