From 26b54570d47c9b33f2204b12bb2b86b552de89e5 Mon Sep 17 00:00:00 2001 From: Ismael Celis Date: Thu, 18 Jul 2024 22:47:12 +0100 Subject: [PATCH] Reorganise README --- README.md | 342 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 179 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index bedf680..2d862e4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,52 @@ result.valid? # false result.errors # "" ``` +### Specialize your types with `#[]` + +Use `#[]` to make your types match a class. + +```ruby +module Types + include Plumb::Types + + String = Types::Any[::String] + Integer = Types::Any[::Integer] +end + +Types::String.parse("hello") # => "hello" +Types::String.parse(10) # raises "Must be a String" (Plumb::TypeError) +``` + +Plumb ships with basic types already defined, such as `Types::String` and `Types::Integer`. See the full list below. + +The `#[]` method is not just for classes. It works with anything that responds to `#===` + +```ruby +# Match against a regex +Email = Types::String[/@/] # ie Types::Any[String][/@/] + +Email.parse('hello') # fails +Email.parse('hello@server.com') # 'hello@server.com' + +# Or a Range +AdultAge = Types::Integer[18..] +AdultAge.parse(20) # 20 +AdultAge.parse(17) # raises "Must be within 18.."" (Plumb::TypeError) + +# Or literal values +Twenty = Types::Integer[20] +Twenty.parse(20) # 20 +Twenty.parse(21) # type error +``` + +It can be combined with other methods. For example to cast strings as integers, but only if they _look_ like integers. + +```ruby +StringToInt = Types::String[/^\d+$/].transform(::Integer, &:to_i) + +StringToInt.parse('100') # => 100 +StringToInt.parse('100lol') # fails +``` ### `#resolve(value) => Result` @@ -55,8 +101,6 @@ result.value # '10' result.errors # 'must be an Integer' ``` - - ### `#parse(value) => value` `#parse` takes an input value and returns the parsed/coerced value if successful. or it raises an exception if failed. @@ -68,6 +112,80 @@ Types::Integer.parse('10') # raises Plumb::TypeError +### Composite types + +Some built-in types such as `Types::Array` and `Types::Hash` allow defininig array or hash data structures composed of other types. + +```ruby +# A user hash +User = Types::Hash[name: Types::String, email: Email, age: AdultAge] + +# An array of User hashes +Users = Types::Array[User] + +joe = User.parse({ name: 'Joe', email: 'joe@email.com', age: 20}) # returns valid hash +Users.parse([joe]) # returns valid array of user hashes +``` + +More about [Types::Array](#typeshash) and [Types::Array](#typesarray). There's also tuples and hash maps, and it's possible to create your own composite types. + +## Type composition + +At the core, Plumb types are little [Railway-oriented pipelines](https://ismaelcelis.com/posts/composable-pipelines-in-ruby/) that can be composed together with _and_, _or_ and _not_ semantics. Everything else builds on top of these two ideas. + +### Composing types with `#>>` ("And") + +```ruby +Email = Types::String[/@/] +# You can compose procs and lambdas, or other types. +Greeting = Email >> ->(result) { result.valid("Your email is #{result.value}") } + +Greeting.parse('joe@bloggs.com') # "Your email is joe@bloggs.com" +``` + +### Disjunction with `#|` ("Or") + +```ruby +StringOrInt = Types::String | Types::Integer +StringOrInt.parse('hello') # "hello" +StringOrInt.parse(10) # 10 +StringOrInt.parse({}) # raises Plumb::TypeError +``` + +Custom default value logic for non-emails + +```ruby +EmailOrDefault = Greeting | Types::Static['no email'] +EmailOrDefault.parse('joe@bloggs.com') # "Your email is joe@bloggs.com" +EmailOrDefault.parse('nope') # "no email" +``` + +## Composing with `#>>` and `#|` + +This more elaborate example defines a combination of types which, when composed together with `>>` and `|`, can coerce strings or integers into Money instances with currency. + +```ruby +require 'money' + +module Types + include Plumb::Types + + Money = Any[::Money] + IntToMoney = Integer.transform(::Money) { |v| ::Money.new(v, 'USD') } + StringToInt = String.match(/^\d+$/).transform(::Integer, &:to_i) + USD = Money.check { |amount| amount.currency.code == 'UDS' } + ToUSD = Money.transform(::Money) { |amount| amount.exchange_to('USD') } + + FlexibleUSD = (Money | ((Integer | StringToInt) >> IntToMoney)) >> (USD | ToUSD) +end + +FlexibleUSD.parse('1000') # Money(USD 10.00) +FlexibleUSD.parse(1000) # Money(USD 10.00) +FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00) +``` + +You can see more use cases in [the examples directory](/ismasan/plumb/tree/main/examples) + ## Built-in types * `Types::Value` @@ -97,6 +215,10 @@ Types::Integer.parse('10') # raises Plumb::TypeError +### Policies + +Policies are methods that encapsulate common compositions. Plumb ships with some, listed below, and you can also define your own. + ### `#present` Checks that the value is not blank (`""` if string, `[]` if array, `{}` if Hash, or `nil`) @@ -117,14 +239,12 @@ nullable_str.parse('hello') # 'hello' nullable_str.parse(10) # TypeError ``` -Note that this is syntax sugar for +Note that this just encapsulates the following composition: ```ruby nullable_str = Types::String | Types::Nil ``` - - ### `#not` Negates a type. @@ -153,8 +273,6 @@ type.resolve(['a', 'a', 'b']) # Valid type.resolve(['a', 'x', 'b']) # Failure ``` - - ### `#transform` Transform value. Requires specifying the resulting type of the value after transformation. @@ -238,45 +356,6 @@ Same if you want to apply a default to several cases. str = Types::String | ((Types::Nil | Types::Undefined) >> Types::Static['nope'.freeze]) ``` - - -### `#match` and `#[]` - -Checks the value against a regular expression (or anything that responds to `#===`). - -```ruby -email = Types::String.match(/@/) -# Same as -email = Types::String[/@/] -email.parse('hello') # fails -email.parse('hello@server.com') # 'hello@server.com' -``` - -It can be combined with other methods. For example to cast strings as integers, but only if they _look_ like integers. - -```ruby -StringToInt = Types::String[/^\d+$/].transform(::Integer, &:to_i) - -StringToInt.parse('100') # => 100 -StringToInt.parse('100lol') # fails -``` - -It can be used with other `#===` interfaces. - -```ruby -AgeBracket = Types::Integer[21..45] - -AgeBracket.parse(22) # 22 -AgeBracket.parse(20) # fails - -# With literal values -Twenty = Types::Integer[20] -Twenty.parse(20) # 20 -Twenty.parse(21) # type error -``` - - - ### `#build` Build a custom object or class. @@ -310,59 +389,6 @@ Note that this case is identical to `#transform` with a block. StringToMoney = Types::String.transform(Money) { |value| Monetize.parse(value) } ``` -### Other policies - -There's some other built-in "policies" that can be used via the `#policy` method. Helpers such as `#default` and `#present` are shortcuts for this and can also be used via `#policy(default: 'Hello')` or `#policy(:present)` See [custom policies](#custom-policies) for how to define your own policies. - -#### `:respond_to` - -Similar to `Types::Interface`, this is a quick way to assert that a value supports one or more methods. - -```ruby -List = Types::Any.policy(respond_to: :each) -# or -List = Types::Any.policy(respond_to: [:each, :[], :size) -``` - -#### `:excluded_from` - -The opposite of `#options`, this policy validates that the value _is not_ included in a list. - -```ruby -Name = Types::String.policy(excluded_from: ['Joe', 'Joan']) -``` - -#### `:size` - -Works for any value that responds to `#size` and validates that the value's size matches the argument. - -```ruby -LimitedArray = Types::Array[String].policy(size: 10) -LimitedString = Types::String.policy(size: 10) -LimitedSet = Types::Any[Set].policy(size: 10) -``` - -The size is matched via `#===`, so ranges also work. - -```ruby -Password = Types::String.policy(size: 10..20) -``` - -#### `:split` (strings only) - -Splits string values by a separator (default: `,`). - -```ruby -CSVLine = Types::String.split -CSVLine.parse('a,b,c') # => ['a', 'b', 'c'] - -# Or, with custom separator -CSVLine = Types::String.split(/\s*;\s*/) -CSVLine.parse('a;b;c') # => ['a', 'b', 'c'] -``` - - - ### `#check` Pass the value through an arbitrary validation @@ -392,8 +418,6 @@ All scalar types support this: ten = Types::Integer.value(10) ``` - - ### `#meta` and `#metadata` Add metadata to a type @@ -422,7 +446,60 @@ Types::String.transform(Integer, &:to_i).metadata[:type] # Integer TODO: document custom visitors. -## `Types::Hash` +### Other policies + +There's some other built-in "policies" that can be used via the `#policy` method. Helpers such as `#default` and `#present` are shortcuts for this and can also be used via `#policy(default: 'Hello')` or `#policy(:present)` See [custom policies](#custom-policies) for how to define your own policies. + +#### `:respond_to` + +Similar to `Types::Interface`, this is a quick way to assert that a value supports one or more methods. + +```ruby +List = Types::Any.policy(respond_to: :each) +# or +List = Types::Any.policy(respond_to: [:each, :[], :size) +``` + +#### `:excluded_from` + +The opposite of `#options`, this policy validates that the value _is not_ included in a list. + +```ruby +Name = Types::String.policy(excluded_from: ['Joe', 'Joan']) +``` + +#### `:size` + +Works for any value that responds to `#size` and validates that the value's size matches the argument. + +```ruby +LimitedArray = Types::Array[String].policy(size: 10) +LimitedString = Types::String.policy(size: 10) +LimitedSet = Types::Any[Set].policy(size: 10) +``` + +The size is matched via `#===`, so ranges also work. + +```ruby +Password = Types::String.policy(size: 10..20) +``` + +#### `:split` (strings only) + +Splits string values by a separator (default: `,`). + +```ruby +CSVLine = Types::String.split +CSVLine.parse('a,b,c') # => ['a', 'b', 'c'] + +# Or, with custom separator +CSVLine = Types::String.split(/\s*;\s*/) +CSVLine.parse('a;b;c') # => ['a', 'b', 'c'] +``` + + + +### `Types::Hash` ```ruby Employee = Types::Hash[ @@ -515,8 +592,6 @@ Types::Hash[ ] ``` - - #### Merging hash definitions Use `Types::Hash#+` to merge two definitions. Keys in the second hash override the first one's. @@ -527,8 +602,6 @@ Employee = Types::Hash[name: Types::String, company: Types::String] StaffMember = User + Employee # Hash[:name, :age, :company] ``` - - #### Hash intersections Use `Types::Hash#&` to produce a new Hash definition with keys present in both. @@ -537,8 +610,6 @@ Use `Types::Hash#&` to produce a new Hash definition with keys present in both. intersection = User & Employee # Hash[:name] ``` - - #### `Types::Hash#tagged_by` Use `#tagged_by` to resolve what definition to use based on the value of a common key. @@ -558,8 +629,6 @@ Events = Types::Hash.tagged_by( Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition ``` - - #### `Types::Hash#inclusive` Use `#inclusive` to preserve input keys not defined in the hash schema. @@ -600,8 +669,6 @@ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 } User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' } ``` - - ### Hash maps You can also use Hash syntax to define a hash map with specific types for all keys and values: @@ -796,57 +863,6 @@ TODO TODO -## Composing types with `#>>` ("And") - -```ruby -Email = Types::String.match(/@/) -Greeting = Email >> ->(result) { result.valid("Your email is #{result.value}") } - -Greeting.parse('joe@bloggs.com') # "Your email is joe@bloggs.com" -``` - - -## Disjunction with `#|` ("Or") - -```ruby -StringOrInt = Types::String | Types::Integer -StringOrInt.parse('hello') # "hello" -StringOrInt.parse(10) # 10 -StringOrInt.parse({}) # raises Plumb::TypeError -``` - -Custom default value logic for non-emails - -```ruby -EmailOrDefault = Greeting | Types::Static['no email'] -EmailOrDefault.parse('joe@bloggs.com') # "Your email is joe@bloggs.com" -EmailOrDefault.parse('nope') # "no email" -``` - -## Composing with `#>>` and `#|` - -```ruby -require 'money' - -module Types - include Plumb::Types - - Money = Any[::Money] - IntToMoney = Integer.transform(::Money) { |v| ::Money.new(v, 'USD') } - StringToInt = String.match(/^\d+$/).transform(::Integer, &:to_i) - USD = Money.check { |amount| amount.currency.code == 'UDS' } - ToUSD = Money.transform(::Money) { |amount| amount.exchange_to('USD') } - - FlexibleUSD = (Money | ((Integer | StringToInt) >> IntToMoney)) >> (USD | ToUSD) -end - -FlexibleUSD.parse('1000') # Money(USD 10.00) -FlexibleUSD.parse(1000) # Money(USD 10.00) -FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00) -``` - - - ### Recursive types You can use a proc to defer evaluation of recursive definitions.