Hash Schemas

It is possible to define a type for a hash with a known set of keys and corresponding value types. Let's say you want to describe a hash containing the name and the age of a user:

# using simple kernel coercions
user_hash = Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer)

user_hash[name: 'Jane', age: '21']
# => { name: 'Jane', age: 21 }
# :name left untouched and :age was coerced to Integer

If a value doesn't conform to the type, an error is raised:

user_hash[name: :Jane, age: '21']
# => Dry::Types::SchemaError: :Jane (Symbol) has invalid type
#    for :name violates constraints (type?(String, :Jane) failed)

All keys are required by default:

user_hash[name: 'Jane']
# => Dry::Types::MissingKeyError: :age is missing in Hash input

Extra keys are omitted by default:

user_hash[name: 'Jane', age: '21', city: 'London']
# => { name: 'Jane', age: 21 }

Default values

Default types are only evaluated if the corresponding key is missing in the input:

user_hash = Types::Hash.schema(
  name: Types::String,
  age: Types::Integer.default(18)
)
user_hash[name: 'Jane']
# => { name: 'Jane', age: 18 }

# nil violates the constraint
user_hash[name: 'Jane', age: nil]
# => Dry::Types::SchemaError: nil (NilClass) has invalid type
#    for :age violates constraints (type?(Integer, nil) failed)

In order to evaluate default types on nil, wrap your type with a constructor and map nil to Dry::Types::Undefined:

user_hash = Types::Hash.schema(
  name: Types::String,
  age: Types::Integer.
         default(18).
         constructor { |value|
           value.nil? ? Dry::Types::Undefined : value
         }
)

user_hash[name: 'Jane', age: nil]
# => { name: 'Jane', age: 18 }

The process of converting types to constructors like that can be automated, see "Type transformations" below.

Optional keys

By default, all keys are required to present in the input. You can mark a key as optional by adding ? to its name:

user_hash = Types::Hash.schema(name: Types::String, age?: Types::Integer)

user_hash[name: 'Jane']
# => { name: 'Jane' }

Extra keys

All keys not declared in the schema are silently ignored. This behavior can be changed by calling .strict on the schema:

user_hash = Types::Hash.schema(name: Types::String).strict
user_hash[name: 'Jane', age: 21]
# => Dry::Types::UnknownKeysError: unexpected keys [:age] in Hash input

Transforming input keys

Keys are supposed to be symbols but you can attach a key tranformation to a schema, e.g. for converting strings into symbols:

user_hash = Types::Hash.schema(name: Types::String).with_key_transform(&:to_sym)
user_hash['name' => 'Jane']

# => { name: 'Jane' }

Inheritance

Hash schemas can be inherited in a sense you can define a new schema based on an existing one. Declared keys will be merged, key and type transformations will be preserved. The strict option is also passed to the new schema if present.

# Building an empty base schema
StrictSymbolizingHash = Types::Hash.schema({}).strict.with_key_transform(&:to_sym)

user_hash = StrictSymbolizingHash.schema(
  name: Types::String
)

user_hash['name' => 'Jane']
# => { name: 'Jane' }

user_hash['name' => 'Jane', 'city' => 'London']
# => Dry::Types::UnknownKeysError: unexpected keys [:city] in Hash input

Transforming types

A schema can transform types with a block. For example, the following code makes all keys optional:

user_hash = Types::Hash.with_type_transform { |type| type.required(false) }.schema(
  name: Types::String,
  age: Types::Integer
)

user_hash[name: 'Jane']
# => { name: 'Jane' }
user_hash[{}]
# => {}

Type transformations work perfectly with inheritance, you don't have to define same rules more than once:

SymbolizeAndOptionalSchema = Types::Hash.
  .schema({})
  .with_key_transform(&:to_sym)
  .with_type_transform { |type| type.required(false) }

user_hash = SymbolizeAndOptionalSchema.schema(
  name: Types::String,
  age: Types::Integer
)

user_hash['name' => 'Jane']

You can check key name by calling .name on the type argument:

Types::Hash.with_type_transform do |key|
  if key.name.to_s.end_with?('_at')
    key.constructor { |v| Time.iso8601(v) }
  else
    key
  end
end

octocatEdit on GitHub