A development toolkit for Ruby with several small/cohesive abstractions to empower your development workflow - It's totally free of dependencies.

31
3
Ruby

🤷 kind

A development toolkit for Ruby with several small/cohesive abstractions to empower your development workflow - It's totally free of dependencies.

Gem Build Status
Ruby Rails
Maintainability Test Coverage

Motivation:

This project was born to help me with a simple task, create a light and fast type checker (at runtime) for Ruby. The initial idea was to have something to raise an exception when a method or function (procs) received a wrong input.

But through time it was natural the addition of more features to improve the development workflow, like monads (Kind::Maybe, Kind::Either / Kind::Result), enums (Kind::Enum), immutable objects (Kind::ImmutableAttributes), type validation via ActiveModel::Validation, and several abstractions to help the implementation of business logic (Kind::Functional::Steps, Kind::Functional::Action, Kind::Action).

So, I invite you to check out these features to see how they could be useful for you. Enjoy!

Documentation

Version Documentation
unreleased https://github.com/serradura/kind/blob/main/README.md
5.10.0 https://github.com/serradura/kind/blob/v5.x/README.md
4.1.0 https://github.com/serradura/kind/blob/v4.x/README.md
3.1.0 https://github.com/serradura/kind/blob/v3.x/README.md
2.3.0 https://github.com/serradura/kind/blob/v2.x/README.md
1.9.0 https://github.com/serradura/kind/blob/v1.x/README.md

Table of Contents

Compatibility

kind branch ruby activemodel
unreleased main >= 2.1.0, <= 3.0.0 >= 3.2, < 7.0
5.10.0 v5.x >= 2.1.0, <= 3.0.0 >= 3.2, < 7.0
4.1.0 v4.x >= 2.2.0, <= 3.0.0 >= 3.2, < 7.0
3.1.0 v3.x >= 2.2.0, <= 2.7 >= 3.2, < 7.0
2.3.0 v2.x >= 2.2.0, <= 2.7 >= 3.2, <= 6.0
1.9.0 v1.x >= 2.2.0, <= 2.7 >= 3.2, <= 6.0

Note: The activemodel is an optional dependency, it is related with the Kind::Validator.

Installation

Add this line to your application’s Gemfile:

gem 'kind'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install kind

⬆️  Back to Top

Usage

With this gem you can add some kind of type checking at runtime. e.g:

def sum(a, b)
  Kind::Numeric[a] + Kind::Numeric[b]
end

sum(1, 1)   # 2

sum('1', 1) # Kind::Error ("\"1\" expected to be a kind of Numeric")

⬆️  Back to Top

Kind.<Type>[]

By default, basic verifications are strict. So, when you perform Kind::Hash[value] the given value will be returned if it was a Hash, but if not, an error will be raised.

Kind::Hash[nil]  # Kind::Error (nil expected to be a kind of Hash)
Kind::Hash['']   # Kind::Error ("" expected to be a kind of Hash)
Kind::Hash[a: 1] # {a: 1}

⬆️  Back to Top

Kind::<Type>.===()

Use this method to verify if the given object has the expected type.

Kind::Enumerable === {} # true
Kind::Enumerable === '' # false

⬆️  Back to Top

Kind::<Type>.value?()

This method works like .===, but the difference is what happens when you invoke it without arguments.

# Example of calling `.value?` with an argument:

Kind::Enumerable.value?({}) # true
Kind::Enumerable.value?('') # false

When .value? is called without an argument, it will return a lambda which will know how to perform the kind verification.

collection = [ {number: 1}, 'number 2', {number: 3}, :number_4, [:number, 5] ]

collection.select(&Kind::Enumerable.value?) # [{:number=>1}, {:number=>3}, [:number, 5]]

⬆️  Back to Top

Kind::<Type>.or_nil()

But if you don’t need a strict type verification, use the .or_nil method.

Kind::Hash.or_nil('')     # nil
Kind::Hash.or_nil({a: 1}) # {a: 1}

⬆️  Back to Top

Kind::<Type>.or_undefined()

This method works like .or_nil, but it will return a Kind::Undefined instead of nil.

Kind::Hash.or_undefined('')     # Kind::Undefined
Kind::Hash.or_undefined({a: 1}) # {a: 1}

⬆️  Back to Top

Kind::<Type>.or()

This method can return a fallback if the given value isn’t an instance of the expected kind.

Kind::Hash.or({}, [])      # {}
Kind::Hash.or(nil, [])     # nil
Kind::Hash.or(nil, {a: 1}) # {a: 1}

If it doesn’t receive a second argument (the value), it will return a callable that knows how to expose an instance of the expected type or a fallback if the given value is wrong.

collection = [ {number: 1}, 'number 2', {number: 3}, :number_4, [:number, 5] ]

collection.map(&Kind::Hash.or({}))  # [{:number=>1}, {}, {:number=>3}, {}, {}]
collection.map(&Kind::Hash.or(nil)) # [{:number=>1}, nil, {:number=>3}, nil, nil]

An error will be raised if the fallback didn’t have the expected kind or if not nil / Kind::Undefined.

collection = [ {number: 1}, 'number 2', {number: 3}, :number_4, [:number, 5] ]

collection.map(&Kind::Hash.or(:foo)) # Kind::Error (:foo expected to be a kind of Hash)

⬆️  Back to Top

Kind::<Type>.value()

This method ensures that you will have a value of the expected kind. But, in the case of the given value be invalid, this method will require a default value (with the expected kind) to be returned.

Kind::String.value(1, default: '')   # ""

Kind::String.value('1', default: '') # "1"

Kind::String.value('1', default: 1)  # Kind::Error (1 expected to be a kind of String)

⬆️  Back to Top

Kind::<Type>.maybe

This method exposes a typed Kind::Maybe and using it will be possible to apply a sequence of operations in the case of the wrapped value has the expected kind.

Double = ->(value) do
  Kind::Numeric.maybe(value)
               .then { |number| number * 2 }
               .value_or(0)
end

Double.('2') # 0
Double.(2)   # 4

If it is invoked without arguments, it returns the typed Maybe. But, if it receives arguments, it will behave like the Kind::Maybe.wrap method. e.g.

Kind::Integer.maybe #<Kind::Maybe::Typed:0x0000... @kind=Kind::Integer>

Kind::Integer.maybe(0).some?               # true
Kind::Integer.maybe { 1 }.some?            # true
Kind::Integer.maybe(2) { |n| n * 2 }.some? # true

Kind::Integer.maybe { 2 / 0 }.none?          # true
Kind::Integer.maybe(2) { |n| n / 0 }.none?   # true
Kind::Integer.maybe('2') { |n| n * n }.none? # true

Note: You can use Kind::\<Type\>.optional as an alias for Kind::\<Type\>.maybe.

⬆️  Back to Top

Kind::<Type>?

There is a second way to do a type verification and know if one or multiple values has the expected type. You can use the predicate kind methods (Kind::Hash?). e.g:

# Verifying one value
Kind::Enumerable?({}) # true

# Verifying multiple values
Kind::Enumerable?({}, [], Set.new) # true

Like the Kind::<Type>.value? method, if the Kind::<Type>? doesn’t receive an argument, it will return a lambda which will know how to perform the kind verification.

collection = [ {number: 1}, 'number 2', {number: 3}, :number_4, [:number, 5] ]

collection.select(&Kind::Enumerable?) # [{:number=>1}, {:number=>3}, [:number, 5]]

⬆️  Back to Top

Kind::{Array,Hash,String,Set}.empty_or()

This method is available for some type handlers (Kind::Array, Kind::Hash, Kind::String, Kind::Set), and it will return an empty frozen value if the given one hasn’t the expected kind.

Kind::Array.empty_or({})         # []
Kind::Array.empty_or({}).frozen? # true

⬆️  Back to Top

List of all type handlers

Core

  • Kind::Array
  • Kind::Class
  • Kind::Comparable
  • Kind::Enumerable
  • Kind::Enumerator
  • Kind::File
  • Kind::Float
  • Kind::Hash
  • Kind::Integer
  • Kind::IO
  • Kind::Method
  • Kind::Module
  • Kind::Numeric
  • Kind::Proc
  • Kind::Queue
  • Kind::Range
  • Kind::Regexp
  • Kind::String
  • Kind::Struct
  • Kind::Symbol
  • Kind::Time

Stdlib

  • Kind::OpenStruct
  • Kind::Set

Custom

  • Kind::Boolean
  • Kind::Callable
  • Kind::Lambda

⬆️  Back to Top

Creating type handlers

There are two ways to do this, you can create type handlers dynamically or defining a module.

Dynamic creation

Using a class or a module
class User
end

user = User.new

kind_of_user = Kind[User]

# kind_of_user.name
# kind_of_user.kind
# The type handler can return its kind and its name
kind_of_user.name # "User"
kind_of_user.kind # User

# kind_of_user.===
# Can check if a given value is an instance of its kind.
kind_of_user === 0        # false
kind_of_user === User.new # true

# kind_of_user.value?(value)
# Can check if a given value is an instance of its kind.
kind_of_user.value?('')       # false
kind_of_user.value?(User.new) # true

# If it doesn't receive an argument, a lambda will be returned and it will know how to do the type verification.
[0, User.new].select(&kind_of_user.value?) # [#<User:0x0000.... >]

# kind_of_user.or_nil(value)
# Can return nil if the given value isn't an instance of its kind
kind_of_user.or_nil({})       # nil
kind_of_user.or_nil(User.new) # #<User:0x0000.... >

# kind_of_user.or_undefined(value)
# Can return Kind::Undefined if the given value isn't an instance of its kind
kind_of_user.or_undefined([])       # Kind::Undefined
kind_of_user.or_undefined(User.new) # #<User:0x0000.... >

# kind_of_user.or(fallback, value)
# Can return a fallback if the given value isn't an instance of its kind
kind_of_user.or(nil, 0)        # nil
kind_of_user.or(nil, User.new) # #<User:0x0000.... >

# If it doesn't receive a second argument (the value), it will return a callable that knows how to expose an instance of the expected type or a fallback if the given value was wrong.
[1, User.new].map(&kind_of_user.or(nil)) # [nil, #<User:0x0000.... >]

# An error will be raised if the fallback didn't have the expected kind or if not nil / Kind::Undefined.
[0, User.new].map(&kind_of_user.or(:foo)) # Kind::Error (:foo expected to be a kind of User)

# kind_of_user[value]
# Will raise Kind::Error if the given value isn't an instance of the expected kind
kind_of_user[:foo]     # Kind::Error (:foo expected to be a kind of User)
kind_of_user[User.new] # #<User:0x0000.... >

# kind_of_user.value(arg, default:)
# This method ensures that you will have a value of the expected kind. But, in the case of the given value be invalid, this method will require a default value (with the expected kind) to be returned.
kind_of_user.value(User.new, default: User.new) # #<User:0x0000...>

kind_of_user.value('1', default: User.new)      # #<User:0x0000...>

kind_of_user.value('1', default: 1)  # Kind::Error (1 expected to be a kind of User)

# kind_of_user.maybe
# This method returns a typed Kind::Maybe.
kind_of_user.maybe('1').value_or(User.new) # #<User:0x0000...>

⬆️  Back to Top

Using Kind.object(name:, &block)
PositiveInteger = Kind.object(name: 'PositiveInteger') do |value|
  value.kind_of?(Integer) && value > 0
end

# PositiveInteger.name
# PositiveInteger.kind
# The type handler can return its kind and its name
PositiveInteger.name # "PositiveInteger"
PositiveInteger.kind # #<Proc:0x0000.... >

# PositiveInteger.===
# Can check if a given value is an instance of its kind.
PositiveInteger === 1 # true
PositiveInteger === 0 # false

# PositiveInteger.value?(value)
# Can check if a given value is an instance of its kind.
PositiveInteger.value?(1)  # true
PositiveInteger.value?(-1) # false

# If it doesn't receive an argument, a lambda will be returned and it will know how to do the type verification.
[1, 2, 0, 3, -1].select(&PositiveInteger.value?) # [1, 2, 3]

# PositiveInteger.or_nil(value)
# Can return nil if the given value isn't an instance of its kind
PositiveInteger.or_nil(1) # 1
PositiveInteger.or_nil(0) # nil

# PositiveInteger.or_undefined(value)
# Can return Kind::Undefined if the given value isn't an instance of its kind
PositiveInteger.or_undefined(2)  # 2
PositiveInteger.or_undefined(-1) # Kind::Undefined

# PositiveInteger.or(fallback, value)
# Can return a fallback if the given value isn't an instance of its kind
PositiveInteger.or(nil, 1) # 1
PositiveInteger.or(nil, 0) # nil

# If it doesn't receive a second argument (the value), it will return a callable that knows how to expose an instance of the expected type or a fallback if the given value was wrong.
[1, 2, 0, 3, -1].map(&PositiveInteger.or(1))   # [1, 2, 1, 3, 1]
[1, 2, 0, 3, -1].map(&PositiveInteger.or(nil)) # [1, 2, nil, 3, nil]

# An error will be raised if the fallback didn't have the expected kind or if not nil / Kind::Undefined.
[1, 2, 0, 3, -1].map(&PositiveInteger.or(:foo)) # Kind::Error (:foo expected to be a kind of PositiveInteger)

# PositiveInteger[value]
# Will raise Kind::Error if the given value isn't an instance of the expected kind
PositiveInteger[1]    # 1
PositiveInteger[:foo] # Kind::Error (:foo expected to be a kind of PositiveInteger)

# PositiveInteger.value(arg, default:)
# This method ensures that you will have a value of the expected kind. But, in the case of the given value be invalid, this method will require a default value (with the expected kind) to be returned.
PositiveInteger.value(2, default: 1)   # 2

PositiveInteger.value('1', default: 1) # 1

PositiveInteger.value('1', default: 0) # Kind::Error (0 expected to be a kind of PositiveInteger)

# PositiveInteger.maybe
# This method returns a typed Kind::Maybe.
PositiveInteger.maybe(0).value_or(1) # 1

PositiveInteger.maybe(2).value_or(1) # 2

⬆️  Back to Top

Kind:: object

The idea here is to create a type handler inside of the Kind namespace and to do this you will only need to use pure Ruby. e.g:

class User
end

module Kind
  module User
    extend self, ::Kind::Object

    # Define the expected kind of this type handler.
    def kind; ::User; end
  end

  # This how the Kind::<Type>? methods are defined.
  def self.User?(*values)
    KIND.of?(::User, values)
  end
end

# Doing this you will have the same methods of a standard type handler (like: `Kind::Symbol`).

user = User.new

Kind::User[user] # #<User:0x0000...>
Kind::User[{}]   # Kind::Error ({} expected to be a kind of User)

Kind::User?(user) # true
Kind::User?({})   # false

The advantages of this approach are:

  1. You will have a singleton (a unique instance) to be used, so the garbage collector will work less.
  2. You can define additional methods to be used with this kind.

The disadvantage is:

  1. You could overwrite some standard type handler or constant. I believe that this will be hard to happen, but must be your concern if you decide to use this kind of approach.

⬆️  Back to Top

Utility methods

Kind.of_class?()

This method verify if a given value is a Class.

Kind.of_class?(Hash)       # true
Kind.of_class?(Enumerable) # false
Kind.of_class?(1)          # false

⬆️  Back to Top

Kind.of_module?()

This method verify if a given value is a Module.

Kind.of_module?(Hash)       # false
Kind.of_module?(Enumerable) # true
Kind.of_module?(1)          # false

⬆️  Back to Top

Kind.of_module_or_class()

This method return the given value if it is a module or a class. If not, a Kind::Error will be raised.

Kind.of_module_or_class(String) # String
Kind.of_module_or_class(1)      # Kind::Error (1 expected to be a kind of Module/Class)

⬆️  Back to Top

Kind.respond_to()

this method returns the given object if it responds to all of the method names. But if the object does not respond to some of the expected methods, an error will be raised.

Kind.respond_to('', :upcase)         # ""
Kind.respond_to('', :upcase, :strip) # ""

Kind.respond_to(1, :upcase)        # expected 1 to respond to :upcase
Kind.respond_to(2, :to_s, :upcase) # expected 2 to respond to :upcase

⬆️  Back to Top

Kind.of()

There is a second way to do a strict type verification, you can use the Kind.of() method to do this. It receives the kind as the first argument and the value to be checked as the second one.

Kind.of(Hash, {}) # {}
Kind.of(Hash, []) # Kind::Error ([] expected to be a kind of Hash)

⬆️  Back to Top

Kind.of?()

This method can be used to check if one or multiple values have the expected kind.

# Checking one value
Kind.of?(Array, []) # true
Kind.of?(Array, {}) # false

# Checking multiple values
Kind.of?(Enumerable, [], {}) # true
Kind.of?(Hash, {}, {})       # true
Kind.of?(Array, [], {})      # false

If the method receives only the first argument (the kind) a lambda will be returned and it will know how to do the type verification.

[1, '2', 3].select(&Kind.of?(Numeric)) # [1, 3]

⬆️  Back to Top

Kind.value()

This method ensures that you will have a value of the expected kind. But, in the case of the given value be invalid, this method will require a default value (with the expected kind) to be returned.

Kind.value(String, '1', default: '') # "1"

Kind.value(String, 1, default: '')   # ""

Kind.value(String, 1, default: 2)    # Kind::Error (2 expected to be a kind of String)

⬆️  Back to Top

Kind.is()

You can use Kind.is to verify if some class has the expected type as its ancestor.

Kind.is(Hash, String) # false

Kind.is(Hash, Hash)   # true

Kind.is(Enumerable, Hash) # true

The Kind.is also could check the inheritance of Classes/Modules.

#
# Verifying if a class is or inherits from the expected class.
#
class Human; end
class Person < Human; end
class User < Human; end

Kind.is(Human, User)   # true
Kind.is(Human, Human)  # true
Kind.is(Human, Person) # true

Kind.is(Human, Struct) # false

#
# Verifying if the classes included a module.
#
module Human; end
class Person; include Human; end
class User; include Human; end

Kind.is(Human, User)   # true
Kind.is(Human, Human)  # true
Kind.is(Human, Person) # true

Kind.is(Human, Struct) # false

#
# Verifying if a class is or inherits from the expected module.
#
module Human; end
module Person; extend Human; end
module User; extend Human; end

Kind.is(Human, User)   # true
Kind.is(Human, Human)  # true
Kind.is(Human, Person) # true

Kind.is(Human, Struct) # false

⬆️  Back to Top

Utility modules

Kind::Try

The method .call of this module invokes a public method with or without arguments like public_send does, except that if the receiver does not respond to it the call returns nil rather than raising an exception.

Kind::Try.(' foo ', :strip)        # "foo"
Kind::Try.({a: 1}, :[], :a)        # 1
Kind::Try.({a: 1}, :[], :b)        # nil
Kind::Try.({a: 1}, :fetch, :b, 2)  # 2

Kind::Try.(:symbol, :strip)        # nil
Kind::Try.(:symbol, :fetch, :b, 2) # nil

# It raises an exception if the method name isn't a string or a symbol
Kind::Try.({a: 1}, 1, :a) # TypeError (1 is not a symbol nor a string)

This module has the method [] that knows how to create a lambda that will know how to perform the try strategy.

results =
  [
    {},
    {name: 'Foo Bar'},
    {name: 'Rodrigo Serradura'},
  ].map(&Kind::Try[:fetch, :name, 'John Doe'])

p results # ["John Doe", "Foo Bar", "Rodrigo Serradura"]

⬆️  Back to Top

Kind::Dig

The method .call of this module has the same behavior of Ruby dig methods (Hash, Array, Struct, OpenStruct), but it will not raise an error if some step can’t be digged.

s = Struct.new(:a, :b).new(101, 102)
o = OpenStruct.new(c: 103, d: 104)
d = { struct: s, ostruct: o, data: [s, o]}

Kind::Dig.(s, [:a])            # 101
Kind::Dig.(o, [:c])            # 103

Kind::Dig.(d, [:struct, :b])   # 102
Kind::Dig.(d, [:data, 0, :b])  # 102
Kind::Dig.(d, [:data, 0, 'b']) # 102

Kind::Dig.(d, [:ostruct, :d])  # 104
Kind::Dig.(d, [:data, 1, :d])  # 104
Kind::Dig.(d, [:data, 1, 'd']) # 104

Kind::Dig.(d, [:struct, :f])   # nil
Kind::Dig.(d, [:ostruct, :f])  # nil
Kind::Dig.(d, [:data, 0, :f])  # nil
Kind::Dig.(d, [:data, 1, :f])  # nil

Another difference between the Kind::Dig and the native Ruby dig, is that it knows how to extract values from regular objects.

class Person
  attr_reader :name

  def initialize(name)
    @name = name
  end
end

person = Person.new('Rodrigo')

Kind::Dig.(person, [:name])                         # "Rodrigo"

Kind::Dig.({people: [person]}, [:people, 0, :name]) # "Rodrigo"

This module has the method [] that knows how to create a lambda that will know how to perform the dig strategy.

results = [
  { person: {} },
  { person: { name: 'Foo Bar'} },
  { person: { name: 'Rodrigo Serradura'} },
].map(&Kind::Dig[:person, :name])

p results # [nil, "Foo Bar", "Rodrigo Serradura"],

⬆️  Back to Top

Kind::Presence

The method .call of this module returns the given value if it’s present otherwise it will return nil.

Kind::Presence.(true)         # true
Kind::Presence.('foo')        # "foo"
Kind::Presence.([1, 2])       # [1, 2]
Kind::Presence.({a: 3})       # {a: 3}
Kind::Presence.(Set.new([4])) # #<Set: {4}>

Kind::Presence.('')       # nil
Kind::Presence.('   ')    # nil
Kind::Presence.("\t\n\r") # nil
Kind::Presence.("\u00a0") # nil

Kind::Presence.([])       # nil
Kind::Presence.({})       # nil
Kind::Presence.(Set.new)  # nil

Kind::Presence.(nil)      # nil
Kind::Presence.(false)    # nil

# nil will be returned if the given object responds to the method blank? and this method result is true.
MyObject = Struct.new(:is_blank) do
  def blank?
    is_blank
  end
end

my_object = MyObject.new

my_object.is_blank = true

Kind::Presence.(my_object) # nil

my_object.is_blank = false

Kind::Presence.(my_object) # #<struct MyObject is_blank=false>

This module also has the method to_proc, because of this you can make use of the Kind::Presence in methods that receive a block as an argument. e.g:

  ['', [], {}, '1', [2]].map(&Kind::Presence) # [nil, nil, nil, "1", [2]]

⬆️  Back to Top

Kind::Undefined

The Kind::Undefined constant can be used to distinguish the usage of nil.

If you are interested, check out the tests to understand its methods.

⬆️  Back to Top

Kind::Maybe

The Kind::Maybe is used when a series of computations (in a chain of map callings) could return nil or Kind::Undefined at any point.

optional =
  Kind::Maybe.new(2)
             .map { |value| value * 2 }
             .map { |value| value * 2 }

puts optional.value # 8
puts optional.some? # true
puts optional.none? # false
puts optional.value_or(0) # 8
puts optional.value_or { 0 } # 8

#################
# Returning nil #
#################

optional =
  Kind::Maybe.new(3)
             .map { nil }
             .map { |value| value * 3 }

puts optional.value # nil
puts optional.some? # false
puts optional.none? # true
puts optional.value_or(0) # 0
puts optional.value_or { 0 } # 0

#############################
# Returning Kind::Undefined #
#############################

optional =
  Kind::Maybe.new(4)
             .map { Kind::Undefined }
             .map { |value| value * 4 }

puts optional.value # Kind::Undefined
puts optional.some? # false
puts optional.none? # true
puts optional.value_or(1) # 1
puts optional.value_or { 1 } # 1

⬆️  Back to Top

Replacing blocks by lambdas

Add = -> params do
  a, b = Kind::Hash.value_or_empty(params).values_at(:a, :b)

  a + b if Kind::Numeric?(a, b)
end

# --

  Kind::Maybe.new(a: 1, b: 2).map(&Add).value_or(0) # 3

  # --

  Kind::Maybe.new([]).map(&Add).value_or(0) # 0
  Kind::Maybe.new({}).map(&Add).value_or(0) # 0
  Kind::Maybe.new(nil).map(&Add).value_or(0) # 0

⬆️  Back to Top

Kind::Maybe[], Kind::Maybe.wrap() and Kind::Maybe#then method aliases

You can use Kind::Maybe[] (brackets) instead of the .new to transform values in a Kind::Maybe. Another alias is .then to the .map method.

result =
  Kind::Maybe[5]
    .then { |value| value * 5 }
    .then { |value| value + 17 }
    .value_or(0)

puts result # 42

You can also use Kind::Maybe.wrap() instead of the .new method.

result =
  Kind::Maybe
    .wrap(5)
    .then { |value| value * 5 }
    .then { |value| value + 17 }
    .value_or(0)

puts result # 42

⬆️  Back to Top

Replacing blocks by lambdas

Add = -> params do
  a, b = Kind::Hash.value_or_empty(params).values_at(:a, :b)

  a + b if Kind::Numeric?(a, b)
end

# --

Kind::Maybe[a: 1, b: 2].then(&Add).value_or(0) # 3

# --

Kind::Maybe[1].then(&Add).value_or(0) # 0
Kind::Maybe['2'].then(&Add).value_or(0) # 0
Kind::Maybe[nil].then(&Add).value_or(0) # 0

⬆️  Back to Top

Kind::None() and Kind::Some()

If you need to ensure the return of Kind::Maybe results from your methods/lambdas,
you could use the methods Kind::None and Kind::Some to do this. e.g:

Double = ->(arg) do
  number = Kind::Numeric.or_nil(arg)

  Kind::Maybe[number].then { |number| number * 2 }
end

Add = -> params do
  a, b = Kind::Hash.value_or_empty(params).values_at(:a, :b)

  return Kind::None unless Kind::Numeric?(a, b)

  Kind::Some(a + b)
end

# --

Add.call(1)    # #<Kind::Maybe::None:0x0000... @value=nil>
Add.call({})   # #<Kind::Maybe::None:0x0000... @value=nil>
Add.call(a: 1) # #<Kind::Maybe::None:0x0000... @value=nil>
Add.call(b: 2) # #<Kind::Maybe::None:0x0000... @value=nil>

Add.call(a:1, b: 2) # #<Kind::Maybe::Some:0x0000... @value=3>

# --

Kind::Maybe[a: 1, b: 2].then(&Add).value_or(0) # 3

Kind::Maybe[1].then(&Add).value_or(0) # 0

# --

Add.(a: 2, b: 2).then(&Double).value # 8

⬆️  Back to Top

Kind::Optional

The Kind::Optional constant is an alias for Kind::Maybe. e.g:

result1 =
  Kind::Optional
    .new(5)
    .map { |value| value * 5 }
    .map { |value| value - 10 }
    .value_or(0)

puts result1 # 15

# ---

result2 =
  Kind::Optional[5]
    .then { |value| value * 5 }
    .then { |value| value + 10 }
    .value_or { 0 }

puts result2 # 35

⬆️  Back to Top

Replacing blocks by lambdas

Double = ->(arg) do
  number = Kind::Numeric.or_nil(arg)

  Kind::Maybe[number].then { |number| number * 2 }
end

# --

Kind::Optional[2].then(&Double).value_or(0) # 4

# --

Kind::Optional['2'].then(&Double).value_or(0) # 0
Kind::Optional[nil].then(&Double).value_or(0) # 0

⬆️  Back to Top

Kind::Maybe()

You can use Kind::Maybe(<Type>) or Kind::Optional(<Type>) to create a maybe monad which will return None if the given input hasn’t the expected type. e.g:

result1 =
  Kind::Maybe(Numeric)
    .wrap(5)
    .then { |value| value * 5 }
    .value_or { 0 }

puts result1 # 25

# ---

result2 =
  Kind::Optional(Numeric)
    .wrap('5')
    .then { |value| value * 5 }
    .value_or { 0 }

puts result2 # 0

This typed maybe has the same methods of Kind::Maybe class. e.g:

Kind::Maybe(Numeric)[5]
Kind::Maybe(Numeric).new(5)
Kind::Maybe(Numeric).wrap(5)

# ---

Kind::Optional(Numeric)[5]
Kind::Optional(Numeric).new(5)
Kind::Optional(Numeric).wrap(5)

Real world examples

It is very common the need to avoid some operation when a method receives the wrong input.
In these scenarios, you could create a maybe monad that will return None if the given input hasn’t the expected type. e.g:

def person_name(params)
  Kind::Maybe(Hash)
    .wrap(params)
    .then  { |hash| hash.values_at(:first_name, :last_name) }
    .then  { |names| names.map(&Kind::Presence).tap(&:compact!) }
    .check { |names| names.size == 2 }
    .then  { |(first_name, last_name)| "#{first_name} #{last_name}" }
    .value_or { 'John Doe' }
end

person_name('')   # "John Doe"
person_name(nil)  # "John Doe"

person_name(first_name: 'Rodrigo')   # "John Doe"
person_name(last_name: 'Serradura')  # "John Doe"

person_name(first_name: 'Rodrigo', last_name: 'Serradura') # "Rodrigo Serradura"

#
# See below the previous implementation without using the maybe monad.
#
def person_name(params)
  default = 'John Doe'

  return default unless params.kind_of?(Hash)

  names = params.values_at(:first_name, :last_name).map(&Kind::Presence).tap(&:compact!)

  return default if names.size != 2

  first_name, last_name = names

  "#{first_name} #{last_name}"
end

To finish follows an example of how to use the Maybe monad to handle arguments in coupled methods.

module PersonIntroduction1
  extend self

  def call(params)
    optional = Kind::Maybe(Hash).wrap(params)

    "Hi my name is #{full_name(optional)}, I'm #{age(optional)} years old."
  end

  private

    def full_name(optional)
      optional.map { |hash| "#{hash[:first_name]} #{hash[:last_name]}".strip }
              .presence
              .value_or { 'John Doe' }
    end

    def age(optional)
      optional.dig(:age).value_or(0)
    end
end

#
# See below the previous implementation without using an optional.
#
module PersonIntroduction2
  extend self

  def call(params)
    "Hi my name is #{full_name(params)}, I'm #{age(params)} years old."
  end

  private

    def full_name(params)
      default = 'John Doe'

      case params
      when Hash then
        Kind::Presence.("#{params[:first_name]} #{params[:last_name]}".strip) || default
      else default
      end
    end

    def age(params)
      case params
      when Hash then params.fetch(:age, 0)
      else 0
      end
    end
end

⬆️  Back to Top

Error handling

Kind::Maybe.wrap {}

The Kind::Maybe#wrap can receive a block, and if an exception (at StandardError level) happening, this will generate a None result.

Kind::Maybe(Numeric)
  .wrap { 2 / 0 } # #<Kind::Maybe::None:0x0000... @value=#<ZeroDivisionError: divided by 0>>

Kind::Maybe(Numeric)
  .wrap(2) { |number| number / 0 } # #<Kind::Maybe::None:0x0000... @value=#<ZeroDivisionError: divided by 0>>

Kind::Maybe.map! or Kind::Maybe.then!

By default the Kind::Maybe#map and Kind::Maybe#then intercept exceptions at the StandardError level. So if an exception was intercepted a None will be returned.

# Handling StandardError exceptions
result1 = Kind::Maybe[2].map { |number| number / 0 }
result1.none? # true
result1.value # #<ZeroDivisionError: divided by 0>

result2 = Kind::Maybe[3].then { |number| number / 0 }
result2.none? # true
result2.value # #<ZeroDivisionError: divided by 0>

But there are versions of these methods (Kind::Maybe#map! and Kind::Maybe#then!) that allow the exception leak, so, the user must handle the exception by himself or use this method when he wants to see the error be raised.

# Leaking StandardError exceptions
Kind::Maybe[2].map! { |number| number / 0 } # ZeroDivisionError (divided by 0)

Kind::Maybe[2].then! { |number| number / 0 } # ZeroDivisionError (divided by 0)

Note: If an exception (at StandardError level) is returned by the methods #then, #map it will be resolved as None.

⬆️  Back to Top

Kind::Maybe#try

If you don’t want to use #map/#then to access the value, you could use the #try method to access it. So, if the value wasn’t nil or Kind::Undefined, the some monad will be returned.

object = 'foo'

Kind::Maybe[object].try(:upcase).value # "FOO"

Kind::Maybe[{}].try(:fetch, :number, 0).value # 0

Kind::Maybe[{number: 1}].try(:fetch, :number).value # 1

Kind::Maybe[object].try { |value| value.upcase }.value # "FOO"

#############
# Nil value #
#############

object = nil

Kind::Maybe[object].try(:upcase).value # nil

Kind::Maybe[object].try { |value| value.upcase }.value # nil

#########################
# Kind::Undefined value #
#########################

object = Kind::Undefined

Kind::Maybe[object].try(:upcase).value # nil

Kind::Maybe[object].try { |value| value.upcase }.value # nil

Note: You can use the #try method with Kind::Optional objects.

⬆️  Back to Top

Kind::Maybe#try!

Has the same behavior of its #try, but it will raise an error if the value doesn’t respond to the expected method.

Kind::Maybe[{}].try(:upcase)  # => #<Kind::Maybe::None:0x0000... @value=nil>

Kind::Maybe[{}].try!(:upcase) # => NoMethodError (undefined method `upcase' for {}:Hash)

Note: You can also use the #try! method with Kind::Optional objects.

⬆️  Back to Top

Kind::Maybe#dig

Has the same behavior of Ruby dig methods (Hash, Array, Struct, OpenStruct), but it will not raise an error if some value can’t be digged.

[nil, 1, '', /x/].each do |value|
  p Kind::Maybe[value].dig(:foo).value # nil
end

# --

a = [1, 2, 3]

Kind::Maybe[a].dig(0).value # 1

Kind::Maybe[a].dig(3).value # nil

# --

h = { foo: {bar: {baz: 1}}}

Kind::Maybe[h].dig(:foo).value             # {bar: {baz: 1}}
Kind::Maybe[h].dig(:foo, :bar).value       # {baz: 1}
Kind::Maybe[h].dig(:foo, :bar, :baz).value # 1

Kind::Maybe[h].dig(:foo, :bar, 'baz').value # nil

# --

i = { foo: [{'bar' => [1, 2]}, {baz: [3, 4]}] }

Kind::Maybe[i].dig(:foo, 0, 'bar', 0).value # 1
Kind::Maybe[i].dig(:foo, 0, 'bar', 1).value # 2
Kind::Maybe[i].dig(:foo, 0, 'bar', -1).value # 2

Kind::Maybe[i].dig(:foo, 0, 'bar', 2).value # nil

# --

s = Struct.new(:a, :b).new(101, 102)
o = OpenStruct.new(c: 103, d: 104)
b = { struct: s, ostruct: o, data: [s, o]}

Kind::Maybe[s].dig(:a).value            # 101
Kind::Maybe[b].dig(:struct, :b).value   # 102
Kind::Maybe[b].dig(:data, 0, :b).value  # 102
Kind::Maybe[b].dig(:data, 0, 'b').value # 102

Kind::Maybe[o].dig(:c).value            # 103
Kind::Maybe[b].dig(:ostruct, :d).value  # 104
Kind::Maybe[b].dig(:data, 1, :d).value  # 104
Kind::Maybe[b].dig(:data, 1, 'd').value # 104

Kind::Maybe[s].dig(:f).value           # nil
Kind::Maybe[o].dig(:f).value           # nil
Kind::Maybe[b].dig(:struct, :f).value  # nil
Kind::Maybe[b].dig(:ostruct, :f).value # nil
Kind::Maybe[b].dig(:data, 0, :f).value # nil
Kind::Maybe[b].dig(:data, 1, :f).value # nil

Note: You can also use the #dig method with Kind::Optional objects.

Kind::Maybe#check

This method will return the current Some after verify if the block output is truthy. e.g:

Kind::Maybe(Array)
  .wrap(['Rodrigo', 'Serradura'])
  .then  { |names| names.map(&Kind::Presence).tap(&:compact!) }
  .check { |names| names.size == 2 }
  .value # ["Rodrigo", "Serradura"]

⬆️  Back to Top

Kind::Maybe#presence

This method will return None if the wrapped value wasn’t present.

result = Kind::Maybe(Hash).wrap(foo: '').dig(:foo).presence
result.none? # true
result.value # nil

⬆️  Back to Top

Kind::Empty

When you define a method that has default arguments, for certain data types, you will always create a new object in memory. e.g:

def something(params = {})
  params.object_id
end

puts something # 70312470300460
puts something # 70312470295800
puts something # 70312470278400
puts something # 70312470273800

So, to avoid an unnecessary allocation in memory, the kind gem exposes some frozen objects to be used as default values.

  • Kind::Empty::SET
  • Kind::Empty::HASH
  • Kind::Empty::ARRAY
  • Kind::Empty::STRING

Usage example:

def do_something(value, with_options: Kind::Empty::HASH)
  # ...
end

Defining Empty as Kind::Empty an alias

You can require kind/empty/constant to define Empty as a Kind::Empty alias. But, a LoadError will be raised if there is an already defined constant Empty.

So if you required this file, the previous example could be written like this:

def do_something(value, with_options: Empty::HASH)
  # ...
end

Follows the list of constants if the alias was defined:

  • Empty::SET
  • Empty::HASH
  • Empty::ARRAY
  • Empty::STRING

⬆️  Back to Top

Kind::Validator (ActiveModel::Validations)

This module enables the capability to validate types via ActiveModel::Validations >= 3.2, < 7.0. e.g

class Person
  include ActiveModel::Validations

  attr_accessor :first_name, :last_name

  validates :first_name, :last_name, kind: String
end

And to make use of it, you will need to do an explicitly require. e.g:

# In some Gemfile
gem 'kind', require: 'kind/validator'

# In some .rb file
require 'kind/validator'

⬆️  Back to Top

Usage

Object#===

validates :name, kind: { of: String }

Use an array to verify if the attribute is an instance of one of the classes/modules.

validates :status, kind: { of: [String, Symbol]}

Because of kind verification be made via === you can use type handlers as the expected kinds.

validates :alive, kind: Kind::Boolean

⬆️  Back to Top

Kind.is

#
# Verifying if the attribute value is the class or a subclass.
#
class Human; end
class Person < Human; end
class User < Human; end

validates :human_kind, kind: { is: Human }

#
# Verifying if the attribute value is the module or if it is a class that includes the module
#
module Human; end
class Person; include Human; end
class User; include Human; end

validates :human_kind, kind: { is: Human }

#
# Verifying if the attribute value is the module or if it is a module that extends the module
#
module Human; end
module Person; extend Human; end
module User; extend Human; end

validates :human_kind, kind: { is: Human }

# or use an array to verify if the attribute
# is a kind of one those classes/modules.

validates :human_kind, kind: { is: [Person, User] }

⬆️  Back to Top

Object#instance_of?

validates :name, kind: { instance_of: String }

# or use an array to verify if the attribute
# is an instance of one of the classes/modules.

validates :name, kind: { instance_of: [String, Symbol] }

⬆️  Back to Top

Object#respond_to?

validates :handler, kind: { respond_to: :call }

This validation can verify one or multiple methods.

validates :params, kind: { respond_to: [:[], :values_at] }

⬆️  Back to Top

Array.new.all? { |item| item.kind_of?(Class) }

validates :account_types, kind: { array_of: String }

# or use an array to verify if the attribute
# is an instance of one of the classes

validates :account_types, kind: { array_of: [String, Symbol] }

⬆️  Back to Top

Array.new.all? { |item| expected_values.include?(item) }

# Verifies if the attribute value
# is an array with some or all the expected values.

validates :account_types, kind: { array_with: ['foo', 'bar'] }

⬆️  Back to Top

Defining the default validation strategy

By default, you can define the attribute type directly (without a hash). e.g.

validates :name, kind: String
# or
validates :name, kind: [String, Symbol]

To changes this behavior you can set another strategy to validates the attributes types:

Kind::Validator.default_strategy = :instance_of

# Tip: Create an initializer if you are in a Rails application.

And these are the available options to define the default strategy:

  • kind_of (default)
  • instance_of

⬆️  Back to Top

Using the allow_nil and strict options

You can use the allow_nil option with any of the kind validations. e.g.

validates :name, kind: String, allow_nil: true

And as any active model validation, kind validations works with the strict: true
option and with the validates! method. e.g.

validates :first_name, kind: String, strict: true
# or
validates! :last_name, kind: String

⬆️  Back to Top

Similar Projects

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/serradura/kind. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Kind project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.