🛤️

ActiveModel::Validationsを使ってrubyでもpythonのTypedDictのようなクラスを作ってみた

2022/02/09に公開

概要

rubyの関数の引数などの型定義で、定型的なHashが欲しかったので自作してみた。
型定義の仕方をDSL風にしてみた。実行時にしか型チェックはできない。型チェックライブラリによって不要になりそうではあるが色々な事情でrubyバージョンを上げれない時にも簡易的に利用できる。副産物的にActiveModel::Validationsに対応したvalidatorもセットできるようにしてattr_accessorなどとtypeによるvalidatesが増えすぎないようにしてある。ただ、実際はvalidatesを律儀に書いていくほうが使い勝手が良い気はする。実装は以下の通り。

class TypedHash < Hash
  include ActiveModel::Validations

  BUILTIN_TYPES = %i[
    string
    number
    int
    boolean
    callable
    var
  ].freeze

  def self.type(key_name, *rest_validators, type: :var, required: true)
    key_sym = key_name.to_sym

    # getter
    define_method(key_sym) do
      self[key_sym]
    end
    # setter
    define_method("#{key_name}=".to_sym) do |_val|
      store(key_name, value)
    end

    validates(key_sym, exclusion: [nil]) if required
    is_exitsted = proc { |c| !c[key_sym].nil? }
    case type
    when :string
      validates_each key_sym, if: [is_exitsted] do |record, attribute, value|
        record.errors.add(attribute, 'はStringではありません。') unless value.is_a? String
      end
    when :int
      validates key_sym, numericality: {
        only_integer: true
      }, if: [is_exitsted]
    when :number
      validates key_sym, numericality: true, if: [is_exitsted]
    when :boolean
      validates key_sym, inclusion: [true, false], if: [is_exitsted]
    when :callable
      validates_each key_sym, if: [is_exitsted] do |record, attribute, value|
        record.errors.add(attribute, 'はCallableではありません。') unless value.respond_to? :call
      end
    when :var
      nil
    else
      validates_each key_sym, if: [is_exitsted] do |record, attribute, value|
        record.errors.add(attribute, "は#{type.name}ではありません。") unless value.is_a? type
      end
    end
    validates(key_sym, *rest_validators) if rest_validators.present?
  end

  BUILTIN_TYPES.each do |a_type|
    define_singleton_method(a_type, lambda { |key_name, *validators|
      type(key_name, *validators, type: a_type, required: true)
    })
    define_singleton_method("#{a_type}?", lambda { |key_name, *validators|
      type(key_name, *validators, type: a_type, required: false)
    })
  end

  define_singleton_method(:cls, lambda { |kls, key_name, *validators|
    type(key_name, *validators, type: kls, required: true)
  })
  define_singleton_method(:cls?, lambda { |kls, key_name, *validators|
    type(key_name, *validators, type: kls, required: false)
  })

  def initialize(initial_hash = {}, **alternatives)
    super()
    initial_hash.merge(alternatives).each do |key, value|
      store(key, value)
    end
    validate!
  end
end

使い方

型定義を行うクラスメソッドを定義して型指定っぽく実装している。現在はstring,number,int,boolean,callable,var,cls(class)+?を末尾につけることでOptionalな型に対応している。テストはしていないがネストしている型も多分いけるはず。

class Example < TypedHash
  callable :acallable
  string? :str
  number :num
  boolean :false
  cls Date, :date1
  cls? Date, :date2
  var :any
  var? :nullable
end
Example.new(
  acallable: ->{},
  str: 'dummy',
  num: 1,
  false: false,
  date1: Date.new,
  any: 'any'
)

出力はこんな感じ。初期化時のバリデーションエラーが出た場合はそのままraiseエラーされる。

{:acallable=>#<Proc:0x------(pry):82 (lambda)>,
 :str=>"dummy",
 :num=>1,
 :false=>false,
 :date1=>Mon, 01 Jan -4712,
 :any=>"any"}

method_missingなどでアクセサ経由のkeyの追加はできないようにしているが[]オペレータは書き換えていないので、動的にキーと値を追加することはできる。ここの仕様は少し迷ったが普通のhashとして扱う以上動的に追加できる余地を残しておくことにした。実用上も今のところ困っていることはない。

ActiveModel::Validationsの必要な機能だけ抜き出して再実装しても良いかなと思ったが、validatorメソッドを利用できると実装が楽だったのでそのままにした。

もし、型のないrubyで困っている人がいればこういうこともできるよという参考にしていただければというお話でした。

Discussion