djk_monads 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 351088f4c8de33dd072afb7fa93ef5cdbeb4bed569dde6bc1c8e4ef147f3d1d6
4
+ data.tar.gz: ba3766f358d52203f3de0fca4571804b5bfa0923572a896c0259147f7f2929c7
5
+ SHA512:
6
+ metadata.gz: 100910e5eeb79c53b5c21fac1eb9d645848b49524c80447ed7530b437b7a1b6f80c4d7c4e83dbeb2751414217ece7718304002a2583bd5fc440b88c412be1ac5
7
+ data.tar.gz: 0c7eef839bba5d57f6d4cb233de7a8503de1fb3faadebda36646ce1294be648cca1f77b8f658c4735c16773e5267502b7a3071d27d5aca2b791554460421b458
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-09-25
4
+
5
+ - Initial release
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ DJK Monads follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behavior within this project, please contact me at ["dan@dankotowski.dev"](mailto:"dan@dankotowski.dev").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Dan Kotowski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # DJK Monads [![Tests](https://github.com/djkotowski/djk_monads/actions/workflows/ci.yml/badge.svg)](https://github.com/djkotowski/djk_monads/actions/workflows/ci.yml)
2
+
3
+ Rust-style `Result` and `Option` monads for Ruby.
4
+
5
+ - **`Result`**: the outcome of an operation that either succeeds with a value (`Ok`) or fails with an error (`Err`).
6
+ - **`Option`**: a value that is either present (Some) or absent (None).
7
+
8
+ Both support chaining (`map`, `flat_map`), unwrapping with fallbacks, and pattern matching.
9
+
10
+ ## Requirements
11
+
12
+ Ruby 4.0 or newer.
13
+
14
+ ## Installation
15
+
16
+ The gem hasn't been published to RubyGems yet. Install it from GitHub by adding it to your Gemfile:
17
+
18
+ ```ruby
19
+ gem "djk_monads", github: "djkotowski/djk_monads"
20
+ ```
21
+
22
+ Then require it:
23
+
24
+ ```ruby
25
+ require "djk"
26
+
27
+ include DJK::Monads # optional, lets you write Result and Option unqualified
28
+ ```
29
+
30
+ To use the types without the namespace everywhere, define them as top-level constants instead:
31
+
32
+ ```ruby
33
+ DJK::Monads.apply_aliases!
34
+ # => [:ReturnError, :Err, :Ok, :Option, :Result]
35
+ ```
36
+
37
+ This defines `ReturnError`, `Err`, `Ok`, `Option` and `Result` at the top level. Any of those names that's already defined is left alone and left out of the returned list, and a warning naming it is printed to stderr.
38
+
39
+ ## Usage
40
+
41
+ ### Result
42
+
43
+ Build results with `Result.ok`, `Result.err` or `Result.from`. Don't instantiate `Ok` or `Err` directly.
44
+
45
+ ```ruby
46
+ Result.ok(1).map { it + 1 }
47
+ # => Ok<2>
48
+
49
+ Result.err("boom").unwrap_or(0)
50
+ # => 0
51
+
52
+ # Result.from runs the block and wraps any StandardError it raises in an Err
53
+ Result.from { Integer("oops") }.map_err(&:message)
54
+ # => Err<"invalid value for Integer(): \"oops\"">
55
+ ```
56
+
57
+ Chain operations that can fail with `flat_map`. The block must return a `Result`, or a `DJK::Monads::ReturnError` is raised:
58
+
59
+ ```ruby
60
+ Result.ok(2).flat_map { |n| n.even? ? Result.ok(n / 2) : Result.err("odd") }
61
+ # => Ok<1>
62
+ ```
63
+
64
+ Pattern match on the variant:
65
+
66
+ ```ruby
67
+ case Result.from { Integer("42") }
68
+ in Ok[value] then value
69
+ in Err[error] then error.message
70
+ end
71
+ # => 42
72
+ ```
73
+
74
+ Hash patterns work too, with `in Ok[value:]` and `in Err[error:]`.
75
+
76
+ #### Instance methods
77
+
78
+ | Method | `Ok` | `Err` |
79
+ | --- | --- | --- |
80
+ | `ok?` / `err?` | `true` / `false` | `false` / `true` |
81
+ | `map { }` | wraps the block's return value in `Ok` | returns `self` |
82
+ | `map_err { }` | returns `self` | wraps the block's return value in `Err` |
83
+ | `flat_map { }` | returns the block's `Result` | returns `self` |
84
+ | `flat_map_err { }` | returns `self` | returns the block's `Result` |
85
+ | `on_ok { }` / `on_err { }` | runs the block for side effects, returns `self` | same |
86
+ | `flatten` | returns the innermost nested `Result` | same |
87
+ | `unwrap!` | returns the value | raises the wrapped error |
88
+ | `unwrap_err!` | raises `ReturnError` | returns the error |
89
+ | `unwrap_or(default)` | returns the value | returns `default` |
90
+ | `unwrap_or_else { \|error\| }` | returns the value | returns the block's return value |
91
+ | `to_h` | `{ variant: :ok, value: }` | `{ variant: :err, error: }` |
92
+
93
+ `map` does not flatten: if its block returns a `Result`, you get a nested result. Use `flat_map`, or call `flatten`.
94
+
95
+ `Err#unwrap!` raises the wrapped error as is, so it works best when the error is an exception or a message string.
96
+
97
+ #### Working with collections
98
+
99
+ ```ruby
100
+ # Split results into their values and their errors
101
+ Result.partition([Result.ok(1), Result.err("boom"), Result.ok(2)])
102
+ # => [[1, 2], ["boom"]]
103
+
104
+ # Map each element to a Result and collect the values, stopping at the first Err
105
+ Result.traverse(%w[1 2 3]) { |s| Result.from { Integer(s) } }
106
+ # => Ok<[1, 2, 3]>
107
+
108
+ Result.traverse(%w[1 x 3]) { |s| Result.from { Integer(s) } }.map_err(&:message)
109
+ # => Err<"invalid value for Integer(): \"x\"">
110
+
111
+ # Remove every Ok wrapping nil
112
+ Result.compact([Result.ok(nil), Result.ok(1), Result.err(nil)])
113
+ # => [Ok<1>, Err<nil>]
114
+ ```
115
+
116
+ ### Option
117
+
118
+ Build options with `Option.some`, `Option.none` or `Option.from`. None is a single shared instance, and a Some can never wrap `nil`. `Option.some(nil)` raises `ArgumentError`.
119
+
120
+ ```ruby
121
+ Option.from(nil).map { it + 1 }.unwrap_or(0)
122
+ # => 0
123
+
124
+ Option.from({ a: 1 }[:a]).map { it * 10 }.unwrap!
125
+ # => 10
126
+
127
+ Option.none.or_else { Option.some(5) }.unwrap!
128
+ # => 5
129
+
130
+ # Convert to a Result
131
+ Option.from(nil).ok_or("missing")
132
+ # => Err<"missing">
133
+
134
+ # Raise your own error instead of Option::NoneError
135
+ Option.none.expect(KeyError.new("no key"))
136
+ # raises KeyError: no key
137
+ ```
138
+
139
+ `Option#map` returns None when the block returns `nil`. `flat_map` and `or_else` blocks must return an `Option`, or a `DJK::Monads::ReturnError` is raised.
140
+
141
+ Other methods: `some?`, `none?`, `unwrap_or_else { }` and `to_a` (`[value]` for a Some, `[]` for None).
142
+
143
+ ## Development
144
+
145
+ After checking out the repo, run `bin/setup` to install dependencies. Then run `bundle exec rake` to run the specs and RuboCop. CI also checks formatting with `bin/stree check`. `bin/console` opens an interactive prompt with the gem loaded.
146
+
147
+ Formatting uses [Syntax Tree](https://github.com/ruby-syntax-tree/syntax_tree) (`bin/stree write`) and [RuboCop](https://rubocop.org) (`bin/rubocop -a`). A [lefthook](https://github.com/evilmartians/lefthook) pre-commit hook runs both on staged files. Run `bundle exec lefthook install` to enable it.
148
+
149
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `lib/djk/monads/version.rb`, then run `bundle exec rake release`. That creates a git tag for the version, pushes the commits and the tag, and pushes the `.gem` file to [rubygems.org](https://rubygems.org).
150
+
151
+ ## Contributing
152
+
153
+ Bug reports and pull requests are welcome on GitHub at https://github.com/djkotowski/djk_monads. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/djkotowski/djk_monads/blob/main/CODE_OF_CONDUCT.md).
154
+
155
+ ## License
156
+
157
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "result"
4
+
5
+ module DJK
6
+ module Monads
7
+ # The failed variant of a Result, wrapping an error.
8
+ #
9
+ # The error can be any object. Calling +unwrap!+ raises it, so an exception or a message string works best.
10
+ #
11
+ # @example
12
+ # Result.err("boom").unwrap_or(0)
13
+ # # => 0
14
+ class Err < Result
15
+ # @param error [Object] the error to wrap
16
+ def initialize(error)
17
+ super()
18
+ @error = error
19
+ end
20
+
21
+ # @param other [Object] the object to compare against
22
+ # @return [Boolean] true if +other+ is an Err wrapping an error equal to this one
23
+ def ==(other)
24
+ return false unless other.is_a?(Result) && other.err?
25
+
26
+ error == other.unwrap_err!
27
+ end
28
+
29
+ # @return [Array(Object)] the wrapped error in a single element array
30
+ def deconstruct
31
+ [error]
32
+ end
33
+
34
+ # @param _keys [Array<Symbol>, nil] ignored, every key is always returned
35
+ # @return [Hash{Symbol => Object}] the wrapped error under +:error+
36
+ def deconstruct_keys(_keys)
37
+ { error: }
38
+ end
39
+
40
+ # @return [Boolean] always true
41
+ def err?
42
+ true
43
+ end
44
+
45
+ # Collapses nested results, returning the innermost one.
46
+ #
47
+ # @return [Result] the innermost result when the error is a Result, otherwise +self+
48
+ def flatten
49
+ return self unless error.is_a?(Result)
50
+
51
+ error.flatten
52
+ end
53
+
54
+ # Does nothing, since there is no value to chain from.
55
+ #
56
+ # @return [Err] +self+, without calling the block
57
+ def flat_map(&)
58
+ self
59
+ end
60
+
61
+ # Chains another result producing operation onto the wrapped error.
62
+ #
63
+ # @yieldparam error [Object] the wrapped error
64
+ # @yieldreturn [Result] must return a Result
65
+ # @raise [ReturnError] when the block does not return a Result
66
+ # @return [Result] the result returned by the block
67
+ def flat_map_err
68
+ yield(error).tap { raise ReturnError, "block must return a Result" unless it.is_a?(Result) }
69
+ end
70
+
71
+ # @return [String] the inspected error wrapped in <tt>Err<></tt>, such as <tt>Err<"boom"></tt>
72
+ def inspect
73
+ "Err<#{error.inspect}>"
74
+ end
75
+
76
+ # Does nothing, since there is no value to transform.
77
+ #
78
+ # @return [Err] +self+, without calling the block
79
+ def map
80
+ self
81
+ end
82
+
83
+ # Transforms the wrapped error.
84
+ #
85
+ # @yieldparam error [Object] the wrapped error
86
+ # @return [Err] an Err wrapping the block's return value, which is not flattened when it is itself a Result
87
+ def map_err
88
+ Result.err(yield(error))
89
+ end
90
+
91
+ # @return [Boolean] always false
92
+ def ok?
93
+ false
94
+ end
95
+
96
+ # Runs the block with the wrapped error for its side effects.
97
+ #
98
+ # @yieldparam error [Object] the wrapped error
99
+ # @return [Err] +self+, ignoring the block's return value
100
+ def on_err
101
+ yield error
102
+ self
103
+ end
104
+
105
+ # Does nothing, since there is no value.
106
+ #
107
+ # @return [Err] +self+, without calling the block
108
+ def on_ok
109
+ self
110
+ end
111
+
112
+ # @return [Hash{Symbol => Object}] the +:err+ variant along with the wrapped error
113
+ def to_h
114
+ { variant: :err, error: }
115
+ end
116
+
117
+ # Always raises the wrapped error, since there is no value to unwrap.
118
+ #
119
+ # An exception is raised as is, and a string is raised as a RuntimeError with that message. Any other object
120
+ # makes +raise+ fail with a TypeError.
121
+ #
122
+ # @raise [Exception] the wrapped error
123
+ def unwrap!
124
+ raise error
125
+ end
126
+
127
+ # @return [Object] the wrapped error
128
+ def unwrap_err!
129
+ error
130
+ end
131
+
132
+ # @param default [Object] the value to return
133
+ # @return [Object] +default+
134
+ def unwrap_or(default)
135
+ default
136
+ end
137
+
138
+ # @yieldparam error [Object] the wrapped error
139
+ # @return [Object] the block's return value
140
+ def unwrap_or_else
141
+ yield error
142
+ end
143
+
144
+ private
145
+
146
+ attr_reader :error
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "result"
4
+
5
+ module DJK
6
+ module Monads
7
+ # The successful variant of a Result, wrapping a value.
8
+ #
9
+ # @example
10
+ # Result.ok(1).unwrap!
11
+ # # => 1
12
+ class Ok < Result
13
+ # @param value [Object] the value to wrap
14
+ def initialize(value)
15
+ super()
16
+ @value = value
17
+ end
18
+
19
+ # @param other [Object] the object to compare against
20
+ # @return [Boolean] true if +other+ is an Ok wrapping a value equal to this one
21
+ def ==(other)
22
+ return false unless other.is_a?(Result) && other.ok?
23
+
24
+ value == other.unwrap!
25
+ end
26
+
27
+ # @return [Array(Object)] the wrapped value in a single element array
28
+ def deconstruct
29
+ [value]
30
+ end
31
+
32
+ # @param _keys [Array<Symbol>, nil] ignored, every key is always returned
33
+ # @return [Hash{Symbol => Object}] the wrapped value under +:value+
34
+ def deconstruct_keys(_keys)
35
+ { value: }
36
+ end
37
+
38
+ # @return [Boolean] always false
39
+ def err?
40
+ false
41
+ end
42
+
43
+ # Collapses nested results, returning the innermost one.
44
+ #
45
+ # @return [Result] the innermost result when the value is a Result, otherwise +self+
46
+ def flatten
47
+ return self unless value.is_a?(Result)
48
+
49
+ value.flatten
50
+ end
51
+
52
+ # Chains another result producing operation onto the wrapped value.
53
+ #
54
+ # @yieldparam value [Object] the wrapped value
55
+ # @yieldreturn [Result] must return a Result
56
+ # @raise [ReturnError] when the block does not return a Result
57
+ # @return [Result] the result returned by the block
58
+ def flat_map
59
+ yield(value).tap { raise ReturnError, "block must return a Result" unless it.is_a?(Result) }
60
+ end
61
+
62
+ # Does nothing, since there is no error to chain from.
63
+ #
64
+ # @return [Ok] +self+, without calling the block
65
+ def flat_map_err
66
+ self
67
+ end
68
+
69
+ # @return [String] the inspected value wrapped in <tt>Ok<></tt>, such as <tt>Ok<1></tt>
70
+ def inspect
71
+ "Ok<#{value.inspect}>"
72
+ end
73
+
74
+ # Transforms the wrapped value.
75
+ #
76
+ # @yieldparam value [Object] the wrapped value
77
+ # @return [Ok] an Ok wrapping the block's return value, which is not flattened when it is itself a Result
78
+ def map
79
+ Result.ok(yield(value))
80
+ end
81
+
82
+ # Does nothing, since there is no error to transform.
83
+ #
84
+ # @return [Ok] +self+, without calling the block
85
+ def map_err
86
+ self
87
+ end
88
+
89
+ # @return [Boolean] always true
90
+ def ok?
91
+ true
92
+ end
93
+
94
+ # Does nothing, since there is no error.
95
+ #
96
+ # @return [Ok] +self+, without calling the block
97
+ def on_err(&)
98
+ self
99
+ end
100
+
101
+ # Runs the block with the wrapped value for its side effects.
102
+ #
103
+ # @yieldparam value [Object] the wrapped value
104
+ # @return [Ok] +self+, ignoring the block's return value
105
+ def on_ok
106
+ yield(value)
107
+ self
108
+ end
109
+
110
+ # @return [Hash{Symbol => Object}] the +:ok+ variant along with the wrapped value
111
+ def to_h
112
+ { variant: :ok, value: }
113
+ end
114
+
115
+ # @return [Object] the wrapped value
116
+ def unwrap!
117
+ value
118
+ end
119
+
120
+ # Always raises, since there is no error to unwrap.
121
+ #
122
+ # @raise [ReturnError] always
123
+ def unwrap_err!
124
+ raise ReturnError, "cannot unwrap_err! on Ok"
125
+ end
126
+
127
+ # @param _default [Object] ignored
128
+ # @return [Object] the wrapped value
129
+ def unwrap_or(_default)
130
+ value
131
+ end
132
+
133
+ # @return [Object] the wrapped value, without calling the block
134
+ def unwrap_or_else(&)
135
+ value
136
+ end
137
+
138
+ private
139
+
140
+ attr_reader :value
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module DJK
6
+ module Monads
7
+ # An optional value that is either present (Some) or absent (None).
8
+ #
9
+ # Build options with Option.some, Option.none, or Option.from. None is a single shared instance, and a Some can
10
+ # never wrap +nil+.
11
+ #
12
+ # @example
13
+ # Option.from(1).map { it + 1 }.unwrap_or(0)
14
+ # # => 2
15
+ #
16
+ # Option.from(nil).map { it + 1 }.unwrap_or(0)
17
+ # # => 0
18
+ class Option
19
+ include Singleton
20
+
21
+ # Raised by +unwrap!+ when called on None.
22
+ class NoneError < StandardError
23
+ end
24
+
25
+ # @api private
26
+ # @param value [Object, nil] the value to wrap, or +nil+ for None
27
+ def initialize(value: nil)
28
+ @value = value
29
+ end
30
+
31
+ # @param other [Object] the object to compare against
32
+ # @return [Boolean] true if both are None, or both are a Some wrapping equal values
33
+ def ==(other)
34
+ return false unless other.is_a?(Option)
35
+ return false if some? && other.none?
36
+ return true if none? && other.none?
37
+
38
+ value == other.unwrap!
39
+ end
40
+
41
+ # Returns the wrapped value, raising +error+ instead of a NoneError when the option is None.
42
+ #
43
+ # @param error [Exception, String] the error to raise for None
44
+ # @raise [Exception] +error+ when the option is None
45
+ # @return [Object] the wrapped value
46
+ def expect(error)
47
+ unwrap!
48
+ rescue NoneError
49
+ raise error
50
+ end
51
+
52
+ # Chains another option producing operation onto a Some, passing through None untouched.
53
+ #
54
+ # @yieldparam value [Object] the wrapped value, only for a Some
55
+ # @yieldreturn [Option] must return an Option
56
+ # @raise [ReturnError] when the block does not return an Option
57
+ # @return [Option] the option returned by the block, or +self+ when the receiver is None
58
+ def flat_map
59
+ return self if none?
60
+
61
+ yield(value).tap { raise ReturnError, "block must return an Option" unless it.is_a?(Option) }
62
+ end
63
+
64
+ # Transforms the value of a Some, passing through None untouched.
65
+ #
66
+ # @yieldparam value [Object] the wrapped value, only for a Some
67
+ # @return [Option] a Some wrapping the block's return value, None when the block returns +nil+, or +self+ when
68
+ # the receiver is None
69
+ def map
70
+ return self if none?
71
+
72
+ Option.from(yield(value))
73
+ end
74
+
75
+ # Converts the option into a Result.
76
+ #
77
+ # @param error [Object] the error to wrap when the option is None
78
+ # @return [Ok, Err] an Ok wrapping the value of a Some, or an Err wrapping +error+ for None
79
+ def ok_or(error)
80
+ some? ? Result.ok(value) : Result.err(error)
81
+ end
82
+
83
+ # Falls back to another option when the receiver is None.
84
+ #
85
+ # @yieldreturn [Option] must return an Option, only called for None
86
+ # @raise [ReturnError] when the block does not return an Option
87
+ # @return [Option] +self+ for a Some, or the option returned by the block for None
88
+ def or_else
89
+ some? ? self : yield.tap { raise ReturnError, "block must return an Option" unless it.is_a?(Option) }
90
+ end
91
+
92
+ # @return [Boolean] true if the option is None
93
+ def none?
94
+ value.nil?
95
+ end
96
+
97
+ # @return [Boolean] true if the option is a Some
98
+ def some?
99
+ !none?
100
+ end
101
+
102
+ # Returns the wrapped value, raising when the option is None.
103
+ #
104
+ # @raise [NoneError] when the option is None
105
+ # @return [Object] the wrapped value
106
+ def unwrap!
107
+ raise NoneError, "unwrap! called on None" if none?
108
+
109
+ value
110
+ end
111
+
112
+ # Returns the wrapped value, falling back to +default+ when the option is None.
113
+ #
114
+ # @param default [Object] the value to return for None
115
+ # @return [Object] the wrapped value or +default+
116
+ def unwrap_or(default)
117
+ none? ? default : value
118
+ end
119
+
120
+ # Returns the wrapped value, falling back to the block's return value when the option is None.
121
+ #
122
+ # @yieldreturn [Object] the value to return, only called for None
123
+ # @return [Object] the wrapped value or the block's return value
124
+ def unwrap_or_else
125
+ none? ? yield : value
126
+ end
127
+
128
+ # @return [Array] the wrapped value in a single element array for a Some, or an empty array for None
129
+ def to_a
130
+ some? ? [value] : []
131
+ end
132
+
133
+ # Wraps a value that may be +nil+.
134
+ #
135
+ # @param value [Object, nil] the value to wrap
136
+ # @return [Option] None when +value+ is +nil+, otherwise a Some wrapping +value+
137
+ def self.from(value)
138
+ value.nil? ? none : some(value)
139
+ end
140
+
141
+ # @return [Option] the shared None instance
142
+ def self.none = instance
143
+
144
+ # Wraps a value that must not be +nil+.
145
+ #
146
+ # @param value [Object] the value to wrap
147
+ # @raise [ArgumentError] when +value+ is +nil+
148
+ # @return [Option] a Some wrapping +value+
149
+ def self.some(value)
150
+ raise ArgumentError, "value cannot be nil" if value.nil?
151
+
152
+ new(value:)
153
+ end
154
+
155
+ private
156
+
157
+ attr_reader :value
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DJK
4
+ module Monads
5
+ # The outcome of an operation that can either succeed with a value (Ok) or fail with an error (Err).
6
+ #
7
+ # Build results with Result.ok, Result.err, or Result.from rather than instantiating the subclasses directly.
8
+ #
9
+ # @abstract Subclassed by Ok and Err, which implement every instance method.
10
+ # @example
11
+ # Result.ok(1).map { it + 1 }
12
+ # # => Ok<2>
13
+ #
14
+ # case Result.from { Integer("oops") }
15
+ # in Ok[value] then value
16
+ # in Err[error] then error.message
17
+ # end
18
+ # # => "invalid value for Integer(): \"oops\""
19
+ class Result
20
+ # Compares this result with another object.
21
+ #
22
+ # @abstract
23
+ # @param other [Object] the object to compare against
24
+ # @return [Boolean] true if +other+ is a Result of the same variant wrapping an equal value
25
+ def ==(other)
26
+ end
27
+
28
+ # Destructures the result for array patterns, exposing the wrapped value or error.
29
+ #
30
+ # @abstract
31
+ # @return [Array] a single element array holding the value (Ok) or the error (Err)
32
+ def deconstruct
33
+ end
34
+
35
+ # Destructures the result for hash patterns.
36
+ #
37
+ # @abstract
38
+ # @param keys [Array<Symbol>, nil] the keys requested by the pattern
39
+ # @return [Hash] the wrapped value or error keyed by variant
40
+ def deconstruct_keys(keys)
41
+ end
42
+
43
+ # @abstract
44
+ # @return [Boolean] true if the result is an Err
45
+ def err?
46
+ end
47
+
48
+ # Collapses a result wrapping another result into a single result.
49
+ #
50
+ # @abstract
51
+ # @return [Result] the innermost result, or +self+ when nothing is nested
52
+ def flatten
53
+ end
54
+
55
+ # Chains another result producing operation onto an Ok, passing through an Err untouched.
56
+ #
57
+ # @abstract
58
+ # @yieldparam value [Object] the wrapped value, only for an Ok
59
+ # @yieldreturn [Result] must return a Result
60
+ # @return [Result] the result returned by the block, or +self+ when the receiver is an Err
61
+ def flat_map
62
+ end
63
+
64
+ # Chains another result producing operation onto an Err, passing through an Ok untouched.
65
+ #
66
+ # @abstract
67
+ # @yieldparam error [Object] the wrapped error, only for an Err
68
+ # @yieldreturn [Result] must return a Result
69
+ # @return [Result] the result returned by the block, or +self+ when the receiver is an Ok
70
+ def flat_map_err
71
+ end
72
+
73
+ # @abstract
74
+ # @return [String] a human readable representation of the result
75
+ def inspect
76
+ end
77
+
78
+ # Transforms the value of an Ok, passing through an Err untouched.
79
+ #
80
+ # @abstract
81
+ # @yieldparam value [Object] the wrapped value, only for an Ok
82
+ # @return [Result] an Ok wrapping the block's return value, or +self+ when the receiver is an Err
83
+ def map
84
+ end
85
+
86
+ # Transforms the error of an Err, passing through an Ok untouched.
87
+ #
88
+ # @abstract
89
+ # @yieldparam error [Object] the wrapped error, only for an Err
90
+ # @return [Result] an Err wrapping the block's return value, or +self+ when the receiver is an Ok
91
+ def map_err
92
+ end
93
+
94
+ # @abstract
95
+ # @return [Boolean] true if the result is an Ok
96
+ def ok?
97
+ end
98
+
99
+ # Runs the block for its side effects when the result is an Err.
100
+ #
101
+ # @abstract
102
+ # @yieldparam error [Object] the wrapped error, only for an Err
103
+ # @return [Result] +self+
104
+ def on_err
105
+ end
106
+
107
+ # Runs the block for its side effects when the result is an Ok.
108
+ #
109
+ # @abstract
110
+ # @yieldparam value [Object] the wrapped value, only for an Ok
111
+ # @return [Result] +self+
112
+ def on_ok
113
+ end
114
+
115
+ # @abstract
116
+ # @return [Hash] the result's variant along with its value or error
117
+ def to_h
118
+ end
119
+
120
+ # Returns the wrapped value, raising when the result is an Err.
121
+ #
122
+ # @abstract
123
+ # @raise [Object] the wrapped error when the result is an Err
124
+ # @return [Object] the wrapped value
125
+ def unwrap!
126
+ end
127
+
128
+ # Returns the wrapped error, raising when the result is an Ok.
129
+ #
130
+ # @abstract
131
+ # @raise [ReturnError] when the result is an Ok
132
+ # @return [Object] the wrapped error
133
+ def unwrap_err!
134
+ end
135
+
136
+ # Returns the wrapped value, falling back to +default+ when the result is an Err.
137
+ #
138
+ # @abstract
139
+ # @param default [Object] the value to return for an Err
140
+ # @return [Object] the wrapped value or +default+
141
+ def unwrap_or(default)
142
+ end
143
+
144
+ # Returns the wrapped value, falling back to the block's return value when the result is an Err.
145
+ #
146
+ # @abstract
147
+ # @yieldparam error [Object] the wrapped error, only for an Err
148
+ # @return [Object] the wrapped value or the block's return value
149
+ def unwrap_or_else
150
+ end
151
+
152
+ # Implicit hash conversion, which lets a result be splatted into a hash with <tt>**result</tt>.
153
+ #
154
+ # @return [Hash] the same hash as +to_h+
155
+ def to_hash = to_h
156
+
157
+ # @return [String] the same string as +inspect+
158
+ def to_s = inspect
159
+
160
+ # Removes all instances of <tt>Ok<nil></tt> from +results+, preserving the order of the remaining results.
161
+ #
162
+ # @param results [Array<Result>] the results to compact
163
+ # @return [Array<Result>] +results+ without any Ok wrapping +nil+
164
+ def self.compact(results)
165
+ results.reject { it.ok? && it.unwrap!.nil? }
166
+ end
167
+
168
+ # Creates a new Err.
169
+ #
170
+ # @param error [Object] the error to wrap
171
+ # @return [Err] an Err wrapping +error+
172
+ def self.err(error)
173
+ Err.new(error)
174
+ end
175
+
176
+ # Runs the provided block, wrapping the returned value in Ok. If an exception is raised, returns an Err with
177
+ # that exception instead.
178
+ #
179
+ # Only StandardError and its subclasses are rescued, so exceptions such as NotImplementedError still propagate.
180
+ #
181
+ # @yieldreturn [Object] the value to wrap in an Ok
182
+ # @return [Ok, Err] an Ok wrapping the block's return value, or an Err wrapping the exception it raised
183
+ def self.from
184
+ Result.ok(yield)
185
+ rescue StandardError => e
186
+ Result.err(e)
187
+ end
188
+
189
+ # Creates a new Ok.
190
+ #
191
+ # @param value [Object] the value to wrap
192
+ # @return [Ok] an Ok wrapping +value+
193
+ def self.ok(value)
194
+ Ok.new(value)
195
+ end
196
+
197
+ # Splits results into their unwrapped values and unwrapped errors, preserving their order.
198
+ #
199
+ # @param results [Array<Result>] the results to split
200
+ # @raise [ArgumentError] when any element of +results+ is not a Result
201
+ # @return [Array(Array, Array)] the unwrapped values of every Ok, followed by the unwrapped errors of every Err
202
+ # @example
203
+ # Result.partition([Result.ok(1), Result.err("boom"), Result.ok(2)])
204
+ # # => [[1, 2], ["boom"]]
205
+ def self.partition(results)
206
+ raise ArgumentError, "results must all be Result" unless results.all?(Result)
207
+
208
+ results.each_with_object([[], []]) do |result, (ok, err)|
209
+ result.on_ok { |value| ok << value }.on_err { |error| err << error }
210
+ end
211
+ end
212
+
213
+ # Maps each value in +values+ through a block, collecting the unwrapped values into an <tt>Ok<Array></tt>.
214
+ # Short-circuits on the first Err.
215
+ #
216
+ # @param values [Enumerable] the values to map to results
217
+ # @yieldparam value [Object] each element of +values+
218
+ # @yieldreturn [Result] must return a Result
219
+ # @return [Ok<Array>, Err] all unwrapped values if all block calls returned Ok or the first Err encountered
220
+ # @example
221
+ # Result.traverse([1, 2, 3]) { |n| Result.ok(n * 2) }
222
+ # # => Ok<[2, 4, 6]>
223
+ #
224
+ # Result.traverse([1, 2, 3]) { |n| n == 2 ? Result.err("bad") : Result.ok(n) }
225
+ # # => Err<"bad">
226
+ def self.traverse(values)
227
+ values.reduce(Result.ok([])) do |result, value|
228
+ result.flat_map do |outputs|
229
+ yield(value)
230
+ .tap { raise ReturnError, "block must return a Result" unless it.is_a?(Result) }
231
+ .map { |output| [*outputs, output] }
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DJK
4
+ module Monads
5
+ # Raised when a method or block does not return an expected value.
6
+ class ReturnError < StandardError
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DJK
4
+ module Monads
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/djk/monads.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "monads/version"
4
+ require_relative "monads/return_error"
5
+ require_relative "monads/result"
6
+ require_relative "monads/ok"
7
+ require_relative "monads/err"
8
+ require_relative "monads/option"
9
+
10
+ module DJK
11
+ # Rust-style Result and Option monads.
12
+ module Monads
13
+ class Error < StandardError
14
+ end
15
+
16
+ ALIASES = %i[ReturnError Err Ok Option Result].freeze
17
+ private_constant :ALIASES
18
+
19
+ # Defines ReturnError, Err, Ok, Option, and Result as top level constants, so they can be used without the
20
+ # DJK::Monads namespace.
21
+ #
22
+ # A constant that is already defined at the top level is left untouched, and a warning naming it is printed to
23
+ # stderr. Constants that already point to the matching DJK::Monads class, such as from an earlier call, are skipped
24
+ # without a warning.
25
+ #
26
+ # @return [Array<Symbol>] the names of the constants that were defined by this call
27
+ # @example
28
+ # DJK::Monads.apply_aliases!
29
+ # # => [:ReturnError, :Err, :Ok, :Option, :Result]
30
+ #
31
+ # Result.ok(1)
32
+ # # => Ok<1>
33
+ def self.apply_aliases!
34
+ defined, undefined = ALIASES.partition { Object.const_defined?(it, false) }
35
+ conflicts = defined.reject { Object.const_get(it, false).equal?(const_get(it)) }
36
+ unless conflicts.empty?
37
+ warn "DJK::Monads.apply_aliases! skipped already defined constants: #{conflicts.join(", ")}", uplevel: 1
38
+ end
39
+
40
+ undefined.each { Object.const_set(it, const_get(it)) }
41
+ end
42
+ end
43
+ end
data/lib/djk.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "djk/monads"
4
+
5
+ module DJK # rubocop:disable Style/Documentation
6
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: djk_monads
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dan Kotowski
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A collection of Rust-like monads for Ruby that I'm using in my personal
13
+ projects.
14
+ email:
15
+ - dan@dankotowski.dev
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - CODE_OF_CONDUCT.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - lib/djk.rb
25
+ - lib/djk/monads.rb
26
+ - lib/djk/monads/err.rb
27
+ - lib/djk/monads/ok.rb
28
+ - lib/djk/monads/option.rb
29
+ - lib/djk/monads/result.rb
30
+ - lib/djk/monads/return_error.rb
31
+ - lib/djk/monads/version.rb
32
+ homepage: https://github.com/djkotowski/djk_monads
33
+ licenses:
34
+ - MIT
35
+ metadata:
36
+ allowed_push_host: https://rubygems.org
37
+ homepage_uri: https://github.com/djkotowski/djk_monads
38
+ changelog_uri: https://github.com/djkotowski/djk_monads/blob/main/CHANGELOG.md
39
+ rubygems_mfa_required: 'true'
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 4.0.0
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 4.0.21
55
+ specification_version: 4
56
+ summary: Rust-style monads for Ruby
57
+ test_files: []