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_i は0 になるが、この型の場合はエラーになるので注意。例: Types::Coercible::Integer['1'] #=> 1 、Types::Coercible::Integer['a'] #=> Dry::Types::CoercionError |
params |
HTTPのパラメータ用の型。例:Types::Params::Date['2021-8-15'] #=> Sun, 15 Aug 2021 、Types::Params::Date['2021-8-32'] #=> Dry::Types::CoercionError (invalid date) |
json |
JSON用の型。JSONをパースするわけではなく、JSON.parse した後の値を処理するのを想定しているっぽい。Types::Params::* の型に近い。 |
maybe |
Some かNone を返す。dry-monads gemを追加して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/