safe_and_sound 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/README.md +62 -0
- data/lib/safe_and_sound/functions.rb +70 -0
- data/lib/safe_and_sound/maybe.rb +31 -0
- data/lib/safe_and_sound/result.rb +19 -0
- data/lib/safe_and_sound/type.rb +17 -0
- data/lib/safe_and_sound/variant.rb +78 -0
- data/lib/safe_and_sound/version.rb +5 -0
- data/lib/safe_and_sound.rb +23 -0
- metadata +110 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 8ea298c0193785c3c404e220a34f34bd30f00ced83729c1dc576010e015b7165
|
4
|
+
data.tar.gz: 38455c727045bccff6dfa5cb17795960fcd51356f942aed7c6b982de21267482
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 395c899676cbd7656ace2655062f68b191798cec6ff9a68bdee5bf7b889b9cfd8ace657f74c1aaefc2d7a214fc1f8c4d6463f531064641e9febc31233a8b6b71
|
7
|
+
data.tar.gz: 749967441723e4dea3d01d43bb41cae4d6661a8826eb511b5431181098ad76a372c9634307a47069c2800d476f868202b3d823a2bdb5e74a6fa30b6ffbe30d2f
|
data/CHANGELOG.md
ADDED
data/README.md
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+

|
2
|
+
|
3
|
+
# Safe and Sound: Sum Data Types and utilities for Ruby
|
4
|
+
|
5
|
+
This library gives you and alternative syntax to declare new types/classes.
|
6
|
+
It's inspired by the concise syntax to declare new types in [Elm](https://guide.elm-lang.org/types/custom_types.html) or [Haskell](https://www.schoolofhaskell.com/user/Gabriel439/sum-types)
|
7
|
+
These types share some properties with types referred to as _algebaric data types_, _sum types_ or _union types_.
|
8
|
+
|
9
|
+
We can model similar relationships more verbosely in plain Ruby with classes and subclasses.
|
10
|
+
This library provides some syntactic shortcuts to create these hierarchy.
|
11
|
+
|
12
|
+
```ruby
|
13
|
+
Vehicle = SafeAndSound.new(
|
14
|
+
Car: { horsepower: Integer },
|
15
|
+
Bike: { gears: Integer}
|
16
|
+
)
|
17
|
+
```
|
18
|
+
|
19
|
+
This will create an abstract base class `Vehicle`.
|
20
|
+
Instances can only be created for the __concrete__ subclasses `Car` or `Bike`.
|
21
|
+
The class names act as "constructor" functions and values created that way are immutable.
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
car = Vehicle.car(horsepower: 100)
|
25
|
+
puts car.horsepower # 100
|
26
|
+
|
27
|
+
bike = Vehicle.bike(gears: 'twentyone')
|
28
|
+
# SafeAndSound::WrgonConstructorArgType (gears must be of type Integer but was String)
|
29
|
+
```
|
30
|
+
|
31
|
+
`nil` is **not** a valid constructor argument. Optional values can be modeled with the [`Maybe` type](examples/maybe.rb) that is also provided with the library.
|
32
|
+
|
33
|
+
To add polymorphic behavior we can write functions __without__ having to touch the new types themselves.
|
34
|
+
|
35
|
+
By including the `SafeAndSound::Functions` module we get access to the `chase` function.
|
36
|
+
It immitates the `case` statement but uses the knowledge about our types to make it more safe.
|
37
|
+
|
38
|
+
```ruby
|
39
|
+
include SafeAndSound::Functions
|
40
|
+
|
41
|
+
def to_human(vehicle)
|
42
|
+
chase vehicle do
|
43
|
+
Vehicle::Car, -> { "I'm a car with #{horsepower} horsepower" }
|
44
|
+
Vehicle::Bike, -> { "I'm a bike with #{gears} gears" }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
```
|
48
|
+
|
49
|
+
This offers a stricter version of the case statement.
|
50
|
+
Specifically it makes sure that all variants are handled (unless an `otherwise` block is given).
|
51
|
+
This check will still be only performed at runtime, but as long as there is at least one test executing this
|
52
|
+
`chase` expression we'll get an early, precise exception telling us what's missing.
|
53
|
+
|
54
|
+
If you want a more detailed explanation why working with such objects can be appealing I recommend you watch the
|
55
|
+
[Functional Core, Imperative Shell](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell)
|
56
|
+
episode of the _Destroy all software_ screencast.
|
57
|
+
|
58
|
+
I'm not trying to change how Ruby code is written. This is merely an experiment how far the sum type concept can be taken in terms
|
59
|
+
of making a syntax for it look like the syntax in languages where this concept is more central.
|
60
|
+
|
61
|
+
|
62
|
+
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SafeAndSound
|
4
|
+
##
|
5
|
+
# Include this module to get access to functions that
|
6
|
+
# work on the algebraic data types.
|
7
|
+
module Functions
|
8
|
+
##
|
9
|
+
# This is an interpretation of Ruby's case statement for
|
10
|
+
# ADTs.
|
11
|
+
def chase(value, &block)
|
12
|
+
Chase.new(value).run(block)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class MatchedChaseValueNotAVariant < StandardError; end
|
17
|
+
class MissingChaseBranch < StandardError; end
|
18
|
+
class DuplicateChaseBranch < StandardError; end
|
19
|
+
class ChaseValueNotAVariant < StandardError; end
|
20
|
+
|
21
|
+
##
|
22
|
+
# Instance represent one application of a 'chase'
|
23
|
+
# statement.
|
24
|
+
class Chase
|
25
|
+
NO_MATCH = Object.new
|
26
|
+
NO_FALLBACK = Object.new
|
27
|
+
|
28
|
+
def initialize(object)
|
29
|
+
unless object.respond_to?(:variant_type?)
|
30
|
+
raise ChaseValueNotAVariant,
|
31
|
+
"Matched value in chase expression must be a variant but is a #{object.class.name}"
|
32
|
+
end
|
33
|
+
|
34
|
+
@result = NO_MATCH
|
35
|
+
@object = object
|
36
|
+
@to_match = object.class.superclass.variants.dup
|
37
|
+
@otherwise = NO_FALLBACK
|
38
|
+
end
|
39
|
+
|
40
|
+
def run(block)
|
41
|
+
instance_eval(&block)
|
42
|
+
unless @to_match.empty? || @otherwise != NO_FALLBACK
|
43
|
+
raise MissingChaseBranch,
|
44
|
+
"Missing branches for variants: #{@to_match.map(&:variant_name).join(',')}"
|
45
|
+
end
|
46
|
+
return @otherwise if @result == NO_MATCH
|
47
|
+
|
48
|
+
@result
|
49
|
+
end
|
50
|
+
|
51
|
+
def wenn(variant, lmda)
|
52
|
+
unless variant.is_a?(Class) && variant.superclass == @object.class.superclass
|
53
|
+
raise MatchedChaseValueNotAVariant,
|
54
|
+
"The value matched against must be a variant of #{@object.class.superclass.name} "\
|
55
|
+
"but is a #{variant.class}"
|
56
|
+
end
|
57
|
+
unless @to_match.delete(variant)
|
58
|
+
raise DuplicateChaseBranch,
|
59
|
+
"There are multiple branches for variant: #{variant.variant_name}"
|
60
|
+
end
|
61
|
+
return unless @result == NO_MATCH
|
62
|
+
|
63
|
+
@result = @object.instance_exec(&lmda) if @object.is_a? variant
|
64
|
+
end
|
65
|
+
|
66
|
+
def otherwise(value)
|
67
|
+
@otherwise = value.call
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SafeAndSound
|
4
|
+
Maybe = SafeAndSound.new(
|
5
|
+
Just: { value: Object },
|
6
|
+
Nothing: {}
|
7
|
+
)
|
8
|
+
|
9
|
+
module MaybeMethods
|
10
|
+
def with_default(default)
|
11
|
+
chase self do
|
12
|
+
wenn Maybe::Just, -> { value }
|
13
|
+
wenn Maybe::Nothing, -> { default }
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
module MaybeClassMethods
|
19
|
+
include SafeAndSound::Functions
|
20
|
+
def values(maybes)
|
21
|
+
maybes.inject([]) do |sum, maybe|
|
22
|
+
chase maybe do
|
23
|
+
wenn Maybe::Just, -> { sum.push(value) }
|
24
|
+
wenn Maybe::Nothing, -> { sum }
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
Maybe.include(MaybeMethods)
|
30
|
+
Maybe.extend(MaybeClassMethods)
|
31
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SafeAndSound
|
4
|
+
Result = SafeAndSound.new(
|
5
|
+
Ok: { value: Object },
|
6
|
+
Err: { error: Object }
|
7
|
+
)
|
8
|
+
##
|
9
|
+
# Module with methods we want to add to our Result type
|
10
|
+
module ResultMethods
|
11
|
+
def and_then(&block)
|
12
|
+
chase self do
|
13
|
+
wenn Result::Ok, -> { block.call(value) }
|
14
|
+
wenn Result::Err, -> { self }
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
Result.include ResultMethods
|
19
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SafeAndSound
|
4
|
+
class BaseTypeCannotBeInstantiated < StandardError; end
|
5
|
+
|
6
|
+
##
|
7
|
+
# base class for the newly defined types
|
8
|
+
class Type
|
9
|
+
def initialize
|
10
|
+
raise BaseTypeCannotBeInstantiated, 'You cannot create instances of this type directly but only of its variants'
|
11
|
+
end
|
12
|
+
|
13
|
+
class << self
|
14
|
+
attr_accessor :variants
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SafeAndSound
|
4
|
+
class MissingConstructorArg < StandardError; end
|
5
|
+
class UnknownConstructorArg < StandardError; end
|
6
|
+
class WrgonConstructorArgType < StandardError; end
|
7
|
+
|
8
|
+
##
|
9
|
+
# base class for a variant of the newly defined types
|
10
|
+
module Variant
|
11
|
+
##
|
12
|
+
# Mixin used in Variant types to initialize fields from
|
13
|
+
# constructor arguments.
|
14
|
+
module Initializer
|
15
|
+
def initialize_fields(**args)
|
16
|
+
missing_fields = self.class.fields.keys
|
17
|
+
args.each do |field_name, value|
|
18
|
+
initialize_field(field_name, value)
|
19
|
+
missing_fields.delete(field_name)
|
20
|
+
end
|
21
|
+
|
22
|
+
return if missing_fields.empty?
|
23
|
+
|
24
|
+
raise MissingConstructorArg, "Not all constructor arguments were supplied: #{missing_fields}"
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_s
|
28
|
+
"<#{self.class.superclass.name}:#{self.class.variant_name} ..."
|
29
|
+
end
|
30
|
+
|
31
|
+
def variant_type?
|
32
|
+
true
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def initialize_field(field_name, value)
|
38
|
+
field_type = self.class.fields[field_name]
|
39
|
+
if field_type.nil?
|
40
|
+
raise UnknownConstructorArg,
|
41
|
+
"#{self.class.name} does not have a constructor argument #{field_name}"
|
42
|
+
end
|
43
|
+
|
44
|
+
unless value.is_a?(field_type)
|
45
|
+
raise WrgonConstructorArgType,
|
46
|
+
"#{field_name} must be of type #{field_type} but was #{value.class.name}"
|
47
|
+
end
|
48
|
+
instance_variable_set("@#{field_name}", value)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.build(variant_name, fields, parent_type)
|
53
|
+
new_variant = create_variant_type(parent_type)
|
54
|
+
new_variant.fields = fields
|
55
|
+
new_variant.variant_name = variant_name
|
56
|
+
fields.each { |field, _| new_variant.attr_reader field }
|
57
|
+
parent_type.const_set(variant_name.to_s, new_variant)
|
58
|
+
parent_type.define_singleton_method(variant_name) do |**args|
|
59
|
+
new_variant.new(**args)
|
60
|
+
end
|
61
|
+
new_variant
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.create_variant_type(parent_type)
|
65
|
+
Class.new(parent_type) do
|
66
|
+
include(Initializer)
|
67
|
+
include(Functions)
|
68
|
+
class << self
|
69
|
+
attr_accessor :fields, :variant_name
|
70
|
+
end
|
71
|
+
|
72
|
+
def initialize(**args)
|
73
|
+
initialize_fields(**args)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'safe_and_sound/functions'
|
4
|
+
require 'safe_and_sound/type'
|
5
|
+
require 'safe_and_sound/variant'
|
6
|
+
require 'safe_and_sound/version'
|
7
|
+
|
8
|
+
##
|
9
|
+
# Namespace for the safe_and_sound classes
|
10
|
+
module SafeAndSound
|
11
|
+
def self.new(**variants)
|
12
|
+
new_type = Class.new(Type)
|
13
|
+
|
14
|
+
new_type.variants =
|
15
|
+
variants.map do |variant_name, fields|
|
16
|
+
Variant.build(variant_name, fields, new_type)
|
17
|
+
end
|
18
|
+
new_type
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
require 'safe_and_sound/result'
|
23
|
+
require 'safe_and_sound/maybe'
|
metadata
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: safe_and_sound
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Axel Tetzlaff
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-04-11 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rake
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 13.0.6
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 13.0.6
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: minitest
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '5.15'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '5.15'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: minitest-reporters
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.5'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.5'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rubocop
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.26'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.26'
|
69
|
+
description: A compact DSL to let you declare sum data types and define safe functions
|
70
|
+
on them.
|
71
|
+
email: axel.tetzlaff@gmx.de
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files:
|
75
|
+
- README.md
|
76
|
+
files:
|
77
|
+
- CHANGELOG.md
|
78
|
+
- README.md
|
79
|
+
- lib/safe_and_sound.rb
|
80
|
+
- lib/safe_and_sound/functions.rb
|
81
|
+
- lib/safe_and_sound/maybe.rb
|
82
|
+
- lib/safe_and_sound/result.rb
|
83
|
+
- lib/safe_and_sound/type.rb
|
84
|
+
- lib/safe_and_sound/variant.rb
|
85
|
+
- lib/safe_and_sound/version.rb
|
86
|
+
homepage: https://github.com/axelerator/safe_and_sound
|
87
|
+
licenses:
|
88
|
+
- MIT
|
89
|
+
metadata:
|
90
|
+
rubygems_mfa_required: 'true'
|
91
|
+
post_install_message:
|
92
|
+
rdoc_options: []
|
93
|
+
require_paths:
|
94
|
+
- lib
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
96
|
+
requirements:
|
97
|
+
- - ">="
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: 2.5.0
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
101
|
+
requirements:
|
102
|
+
- - ">="
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: '0'
|
105
|
+
requirements: []
|
106
|
+
rubygems_version: 3.1.6
|
107
|
+
signing_key:
|
108
|
+
specification_version: 4
|
109
|
+
summary: Sum Data Types
|
110
|
+
test_files: []
|