dry-rb/dry-typesメモ

2021/08/15時点、v1.5.1で確認。

参照:https://dry-rb.org/gems/dry-types/master/

dry-typesの導入

gem 'dry-types'

基本的な使い方

# dry-typesを導入
module Types
  include Dry.Types()
end

class Book < Dry::Struct
  attribute :name, Types::String
  attribute :price, Types::Integer
end

# dry-structのインスタンス化時の事前条件チェック
Book.new(name: 'よくわかるRuby', price: 2800)
# => #<Book name="よくわかるRuby" price=2800>
Book.new(name: 'よくわかるRuby', price: '2800')
# => Dry::Struct::Error ([Book.new] "2800" (String) has invalid type for :price violates constraints (type?(Integer, "2800") failed))

# 型に値を指定する場合以下のように [] で指定する
Types::String['value'] # => "value"

# 個別の値の型チェックとデフォルト値へのフィルタ
Types::String.default('blank'.freeze)['x'] # => 'x'
Types::String.default('blank'.freeze)[] # => "blank"
Types::String.default('blank'.freeze)[10]
# => Dry::Types::ConstraintError (10 violates constraints (type?(String, 10) failed))

このように、コンパイルするわけではないし実行時に検査されるため、型というよりも事前条件のチェックという印象である。

独自型の追加

以下のような形で独自型を追加することができる。

# dry-typesを導入
module Types
  include Dry.Types()
end

class Book < Dry::Struct
  attribute :name, Types::String
  attribute :price, Types::Integer
  def to_s
    "#{name}|#{price}"
  end
end

# 独自型を定義する
module Types
  # Integerかつ18以下の値しか許容しない型
  Young = Integer.constrained(lteq: 18)
  # IntegerかStringかSymbolかBookのいずれかしか許容しない型(いわゆる直和型?)
  Foo = Integer | String | Symbol | ::Book
end

Types::Young[18] # => 18
Types::Young[19]
# => Dry::Types::ConstraintError (19 violates constraints (lteq?(18, 19) failed))

Types::Foo[1].to_s # => "1"
Types::Foo[Book.new(name: 'book-name', price: 1000)].to_s
# => "book-name|1000"

型のカテゴリ

dry-typesの型には以下のカテゴリがある。

カテゴリ 説明
nominal ノミナル。「名ばかりの」といった意味。名称通り何もしない型。例:Types::Nominal::Integer['1'] #=> '1'Types::Nominal::Integer[Book] #=> Book
strict 厳格な型チェックが行われる。例:Types::Strict::Integer['1'] #=>Dry::Types::ConstraintError
coercible キャストできるものはキャストする。'a'.to_i0になるが、この型の場合はエラーになるので注意。例: Types::Coercible::Integer['1'] #=> 1Types::Coercible::Integer['a'] #=> Dry::Types::CoercionError
params HTTPのパラメータ用の型。例:Types::Params::Date['2021-8-15'] #=> Sun, 15 Aug 2021Types::Params::Date['2021-8-32'] #=> Dry::Types::CoercionError (invalid date)
json JSON用の型。JSONをパースするわけではなく、JSON.parseした後の値を処理するのを想定しているっぽい。Types::Params::*の型に近い。
maybe SomeNoneを返す。dry-monadsgemを追加してDry::Types.load_extensions(:maybe)を実行する必要がある。

よく確認していないが、Types::IntegerなどはTypes::Strict::Integerのショートハンドっぽい。

使える型はhttps://dry-rb.org/gems/dry-types/master/built-in-types/を参照。

Dry::Types.container._container.keysを実行すると["nominal.string", "nominal.integer",...]のような形でドキュメントに載っていない型も一覧できる(が使って問題が起きないのかは不明)。

optional

通常はnilを許容しないがoptionalを使うと許容するようにもできる。

Types::String[nil]
# => Dry::Types::ConstraintError (nil violates constraints (type?(String, nil) failed))
Types::String.optional[nil] # => nil

# Types::String.optional は (Types::String | Types::Nil) と等価である
(Types::String | Types::Nil)[nil] # => nil
(Types::String | Types::Nil)['1'] # => "1"

default

値が未指定の場合に使われる値を指定することもできる。

# defaultメソッドの引数に指定した値をそのまま返すためfreezeが必要
string_type = Types::String.default('default-string'.freeze)
string_type[] # => 'default-string'

# 毎回違うインスタンスを返したい場合はdefaultにブロックを指定する
# freezeしなくてもよい
string_type = Types::String.default { 'default-string' }

# ただし、以下のようなコードを書いてもエラーにならない
Types::String.default { 1 }[] #=> 1

# defaultが返す値が型にマッチしていることをチェックしたい場合
# ブロック引数に型が渡されるため以下のようにすれば良い
Types::String.default { |type| type[1] }[]
# => Dry::Types::ConstraintError

constructor

型チェック前に値を変換したい場合に使う。

# ブロックの第一引数は指定された値、第二引数は型
Types::String.constructor { |value, type| type[value.to_s] }[1] # => "1"

# ラムダなどを引数に渡す方法もある
l = ->(value, type) { type[value.to_s] }
Types::String.constructor(l)[1] # => "1"
# append, >> は constructor のエイリアス
Types::String.append(l)[1] # => "1"
(Types::String >> l)[1] # => "1"

fallback

型チェック時にエラーが発生した場合、指定した値に差し替えることができる。

Types::String.fallback('fallback-string'.freeze)[1] # => "fallback-string"

# constructorと組み合わせる場合、実行順序で挙動が変わるので注意
Types::String.fallback('fall'.freeze).constructor { |v,t| t[v] }[1]
# => "fall"
Types::String.constructor {|v,t| t[v]}.fallback('fall'.freeze)[1]
# => Dry::Types::ConstraintError

# またconstructor内での変換処理も挙動に影響する
Types::String.fallback('fall'.freeze).constructor { |v,t| t[v.upcase] }['a']
# => "A"
Types::String.fallback('fall'.freeze).constructor { |v,t| t[v.upcase] }[1]
# => Dry::Types::CoercionError (undefined method `upcase' for 1:Integer)
Types::String.fallback('fall'.freeze).constructor { |v,t| t[v].upcase }['a']
# => "A"
Types::String.fallback('fall'.freeze).constructor { |v,t| t[v].upcase }[1]
# => "FALL"

# ブロックを渡すこともできるが
# constructorと違って指定された値のみが渡され、
# 型は渡されない
Types::String.fallback { |v| v.to_s + 'abc' }.constructor { |v,t| t[v].upcase }[1]
# => "1ABC"

fallbackの戻り値が型チェックでvalidであることを保証するやり方はTypes::String.fallback { Types::String['値'] }のようにブロックの中で型を呼び出すしか方法がなさそうである。Types::String.constructor{|v,t|t[v]}.fallback{...}.constructor{...}としてもfallbackに入る前に型不一致でエラーになってしまう。

直和型

詳しくないのでこれを直和型と言って良いのかわからないが、複数の型を指定していずれかの型であれば型チェックをvalidとする方法がある。

(Types::String | Types::Symbol)[:a] # => :a
(Types::String | Types::Symbol)['a'] # => "a"

constrained

型の値に条件を指定する。

# 10を超えて20以下のIntegerを許容する
Types::Integer.constrained(gt: 10, lteq: 20)[20]
# => 20
Types::Integer.constrained(gt: 10, lteq: 20)[21]
# => Dry::Types::ConstraintError (21 violates constraints (lteq?(20, 21) failed))

ちょっと調べてみたが、どうもOR条件を指定する方法は用意されていないっぽい。指定できる条件はdry-logicを利用しているので独自条件を作れば指定できるかと思ったが、Dry::Logic::Predicatesにあるメソッドしか指定できない状態でハードコーディングされていた。

条件はhttps://github.com/dry-rb/dry-logic/blob/master/lib/dry/logic/predicates.rb?付きのメソッドが指定できる(?はとって指定する)。

雑なハックでモンキーパッチをあてて好きな条件を指定できるようにした例。

module Dry::Logic::Predicates
  module Methods
    def valid_by?(func_and_args, input)
      function, *args = func_and_args
      function.call(*args, input)
    end
  end

  extend Methods
end

Types::Integer.constrained(valid_by: [->(input) { input <= 10}])[10]
# => 10
# エラーメッセージが非常にわかりづらくなるのが難点
Types::Integer.constrained(valid_by: [->(input) { input <= 10}])[11]
# => Dry::Types::ConstraintError (11 violates constraints (valid_by?([#<Proc:0x000055f9d24cf2f8 (irb):3 (lambda)>], 11) failed))

# 以下のように特異メソッドをはやせばわかりやすくはなる
l = ->(input) { input <= 10}
def l.inspect
  "Is input <= 10?"
end
Types::Integer.constrained(valid_by: [l])[11]
# => Dry::Types::ConstraintError (11 violates constraints (valid_by?([Is input <= 10?], 11) failed))

Hash

型チェックのあるHashを定義することができる。

module Types
  HttpResultHash = Hash.schema(status_code: Integer, body: String)
end

Types::HttpResultHash[status_code: 200, body: '<html>...</html>']
# => {:status_code=>200, :body=>"<html>...</html>"}

# 余分なキーが指定されても無視される
Types::HttpResultHash[status_code: 200, body: '<html>...</html>', x: 1]
# => {:status_code=>200, :body=>"<html>...</html>"}

# キーが不足している場合はエラー
Types::HttpResultHash[status_code: 200]
# => Dry::Types::MissingKeyError (:body is missing in Hash input)

# 派生させることも可能
http_result_with_size_hash = Types::HttpResultHash.schema(size: Types::Integer)
# => #<Dry::Types[Constrained<Schema<keys={status_code: Constrained<Nominal<Integer> rule=[type?(Integer)]> b...
http_result_with_size_hash[status_code: 200, body: '<html>...</html>', size: 1024]
# => {:status_code=>200, :body=>"<html>...</html>", :size=>1024}

なお、Types::Hash.schemaメソッドの短縮系としてTypes.Hashメソッドが使える。引数は同じである。

defaultなど

defaultなど通常の型チェック時に使えるオプションも使える。ただし、optionalについてはkey名に?を付けなければいけない。

module Types
  # bodyのデフォルトは''、sizeは指定しなくても良い
  HttpResultHash = Hash.schema(status_code: Integer, body: String.default(''), size?: Integer)
end

Types::HttpResultHash[status_code: 204]
# => {:status_code=>204, :body=>""}

余分なキー指定を不許可にする

strictを指定すると余分なキーを受け付けなくなる。

module Types
  HttpResultHash = Hash.schema(status_code: Integer, body?: String).strict
end

Types::HttpResultHash[status_code: 204, size: 1000]
# => Dry::Types::UnknownKeysError (unexpected keys [:size] in Hash input)

キー指定を文字列でも可能にする

with_key_transformを使うと指定されたキーの変換処理を挟むことができる。デフォルトではキーはシンボルのみしか受け付けないが、以下のようにすることで、文字列キーでも受け付けるようにできる。

module Types
  HttpResultHash = Hash.schema(status_code: Integer, body?: String).with_key_transform(&:to_sym)
end
Types::HttpResultHash['status_code' => 204]
# => {:status_code=>204}

Hashのマージ

複数のHash型を1つのHashとしてまとめることができる。

module Types
  NameHash = Hash.schema(first_name: String, last_name: String)
  DayHash = Hash.schema(year: Integer, month: Integer, day: Integer)
  Person = DayHash.merge(NameHash)
end
Types::Person[first_name: 'taro', last_name: 'suzuki', year: 2000, month: 1, day: 1]
# => {:first_name=>"taro", :last_name=>"suzuki", :year=>2000, :month=>1, :day=>1}

with_type_transform

型に対して一律で処理を行う。参考:https://dry-rb.org/gems/dry-types/master/hash-schemas/#transforming-types

配列

Types::Array.ofメソッドを使うと型チェックありの配列を作ることができる。

x = Types::Array.of(Types::String)
# => #<Dry::Types[Constrained<Array<Constrained<Nominal<String> rule=[type?(String)]>> rule=[type?(Array)]>]>
x[[1,2]]
# => Dry::Types::ConstraintError (1 violates constraints (type?(String, 1) failed))
x[['1','2']]
# => ["1", "2"]

# ただし、型付きの配列を作るのではなく、指定された配列に対して型チェックを行うだけなので
# 以下のようにチェックの後に型チェック違反の値を追加してもエラーにはならない
x[['1','2']] << 1
=> ["1", "2", 1]

# 複数型を許容する型チェック配列は以下のようにすれば良い
x = Types::Array.of(Types::String | Types::Integer)
# => #<Dry::Types[Constrained<Array<Sum<Constrained<Nominal<String> rule=[type?(String)]> | Constrained<Nominal<Integer> rule=[ty...
x[[1, 'a']]
# => [1, "a"]

なお、Types::Array.ofメソッドの短縮系としてTypes.Arrayメソッドが使える。引数は同じである。

Enum

列挙型の型チェックを定義することができる。

str_enum = Types::String.enum('a', 'b', 'c')
# => #<Dry::Types[Enum<Constrained<Nominal<String> rule=[type?(String) AND included_in?(["a", "b", "c"])]> values={"a", "b", "c"}>]>
str_enum['a']
# => "a"
str_enum['x']
# => Dry::Types::ConstraintError ("x" violates constraints (included_in?(["a", "b", "c"], "x") failed))

# 値からenum名に対応づけさせることもできる
str_enum = Types::String.enum('a' => 1, 'b' => 2, 'c' => 3)
# => #<Dry::Types[Enum<Constrained<Nominal<String> rule=[type?(String) AND included_in?(["a", "b", "c"])]> mapping={"a"=>1, "b"=>...
str_enum[1]
# => "a"
str_enum[9]
# => Dry::Types::ConstraintError (9 violates constraints (type?(String, 9) AND included_in?(["a", "b", "c"], 9) failed))

# 値に戻すにはmappingメソッドを使う他なさそう
# mappingメソッドはenumメソッドに指定したハッシュを返す
str_enum.mapping['a']
# => 1

# デフォルト指定する場合はenumメソッドより前にdefaultメソッドを指定する
str_enum = Types::String.default('c'.freeze).enum('a', 'b', 'c')

Map

Hashのキーと値の型だけを指定したハッシュ用の型チェックを作る。

hash_map = Types::Hash.map(Types::Symbol, Types::Integer)
# => #<Dry::Types[Map<Constrained<Nominal<Symbol> rule=[type?(Symbol)]> => Constrained<Nominal<Integer> rule=...
hash_map[a: 1, b: 2]
# => {:a=>1, :b=>2}

# 'b'がSymbolではなくStringなのでエラー
hash_map[a: 1, 'b' => 2]
# => Dry::Types::MapError ("b" violates constraints (type?(Symbol, "b") failed))

任意のクラスの型チェックを作る

Types.Instanceを使うと任意のクラスのインスタンスであることをチェックできる。

class Piyo; end
class Fuga < Piyo; end
piyo_type_check = Types.Instance(Piyo)
# => #<Dry::Types[Constrained<Nominal<Piyo> rule=[type?(Piyo)]>]>

# Piyoクラスのインスタンスなのでエラーにならない
piyo_type_check[Piyo.new]
# => #<Piyo:0x000055f9d2071030>
# Piyoクラスの子クラスのインスタンスなのでエラーにならない
piyo_type_check[Fuga.new]
#=> #<Fuga:0x000055f9cf5268a8>
# Piyoクラスのインスタンスではない(PiyoクラスはClassクラスのインスタンス)のでエラー
piyo_type_check[Piyo]
# => Dry::Types::ConstraintError (Piyo violates constraints (type?(Piyo, Piyo) failed))
# Piyoクラスのインスタンスではない(Stringクラスのインスタンス)のでエラー
piyo_type_check['a']
# => Dry::Types::ConstraintError ("a" violates constraints (type?(Piyo, "a") failed))

任意の値であることをチェックする

==比較の場合はTypes.Valueを使い、equal?比較の場合はTypes.Constantを使う。

blood_type = Types.Value('A') | Types.Value('B') | Types.Value('AB') | Types.Value('O')
# => #<Dry::Types[Sum<Constrained<Nominal<String> rule=[eql?("A")]> | Constrained<Nominal<String> rule=[eql?("B")]> | Constrained...
blood_type['A']
# => "A"
blood_type['B']
# => "B"
blood_type['AB']
# => "AB"
blood_type['O']
# => "O"
blood_type['X']
# => Dry::Types::ConstraintError ("X" violates constraints (eql?("O", "X") failed))

コンストラクタを作る

型チェック前に値の変換を差し込むconstructorとは別なので注意。コンストラクタを作るメソッドはConstructorメソッド。

class Person
    attr_accessor :first_name, :last_name, :age

    def initialize(first_name: , last_name: , age: )
      self.first_name = first_name
      self.last_name = last_name
      self.age = age
    end

    def self.build(name: , age:)
      first_name, last_name = name.split(' ')
      self.new(first_name: first_name, last_name: last_name, age: age)
    end
end

# Person.new(first_name: 'taro', last_name: 'suzuki', age: 18)
# が実行される
person_creator = Types.Constructor(Person)
person_creator[first_name: 'taro', last_name: 'suzuki', age: 18]
# => #<Person:0x000055f9d028da50 @first_name="taro", @last_name="suzuki", @age=18>

# Person.build(name: 'taro suzuki', age: 18)
# が実行される
person_creator = Types.Constructor(Person, Person.method(:build))
person_creator[name: 'taro suzuki', age: 18]
# => #<Person:0x000055f9d2663c90 @first_name="taro", @last_name="suzuki", @age=18>

# 指定したブロックを実行する
person_creator = Types.Constructor(Person) do |values|
  Person.new(first_name: values[0], last_name: values[1], age: values[2])
end
person_creator[['taro', 'suzuki', 18]]
# => #<Person:0x000055f9d283d598 @first_name="taro", @last_name="suzuki", @age=18>

上記の例のようにブロックを用いない限り、基本的にコンストラクタとして指定するメソッドはキーワード引数で宣言しなければならない。def initialize(a, b)に対してTypes.Constructor(Klazz)[1, 2]Types.Constructor(Klazz)[[1, 2]]のような指定をしてもエラーになる。ただ以下のようなコンストラクタを定義する方法もある。

class Klazz
  def initialize(options)
    @a = options[0]
    @b = options[1]
  end
end

Types.Constructor(Klazz)[[1,2]]
# => #<Klazz:0x000055f9d275fc48 @a=1, @b=2>

Types.Constructorの使い道としては編集できないクラスのインスタンス時にアダプターを挟みたい場合が考えられる。ただ、モンキーパッチする方法でも解決できる。あるいは、staticファクトリーメソッドを用意するほどではないが、複数箇所で使われるstaticファクトリーメソッドのようなものが欲しい場合も考えられる。

インターフェースを作る

# 引数にメソッド名のシンボルを指定する。複数指定可
save_type = Types.Interface(:save)
save_type[User.new]

save_type[1]
# => Dry::Types::ConstraintError (1 violates constraints (respond_to?(:save, 1) failed))

# UserはActiveRecordのModel
save_type[User.new]
# => #<User id: nil, name: nil, created_at: nil, updated_at: nil>

Custom Type Builders

https://dry-rb.org/gems/dry-types/master/custom-type-builders/