plumb 0.0.17 → 0.2.0.beta.1

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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +834 -63
  3. data/bench/compare_dry_schema.rb +79 -0
  4. data/bench/compare_dry_types.rb +37 -0
  5. data/bench/compare_parametric_schema.rb +2 -80
  6. data/bench/dry_schema_hash.rb +103 -0
  7. data/bench/dry_types_hash.rb +125 -0
  8. data/bench/json_schema_profile.rb +107 -0
  9. data/bench/plumb_hash.rb +17 -11
  10. data/bench/results_allocations.rb +137 -0
  11. data/bench/sample_data.rb +78 -0
  12. data/examples/command_objects.rb +1 -1
  13. data/examples/concurrent_downloads.rb +16 -9
  14. data/examples/event_registry.rb +6 -1
  15. data/examples/weekdays.rb +1 -1
  16. data/lib/plumb/and.rb +63 -6
  17. data/lib/plumb/any_class.rb +12 -2
  18. data/lib/plumb/array_class.rb +64 -19
  19. data/lib/plumb/attribute_value_match.rb +41 -1
  20. data/lib/plumb/attributes.rb +62 -20
  21. data/lib/plumb/codec.rb +795 -0
  22. data/lib/plumb/composable.rb +463 -39
  23. data/lib/plumb/conjunction.rb +50 -0
  24. data/lib/plumb/constraint.rb +234 -0
  25. data/lib/plumb/covariant_fusion.rb +46 -0
  26. data/lib/plumb/decorator.rb +12 -22
  27. data/lib/plumb/deferred.rb +13 -5
  28. data/lib/plumb/disjunction.rb +112 -0
  29. data/lib/plumb/encoder.rb +207 -0
  30. data/lib/plumb/function.rb +347 -0
  31. data/lib/plumb/hash_class.rb +350 -38
  32. data/lib/plumb/hash_map.rb +62 -14
  33. data/lib/plumb/implementation.rb +247 -0
  34. data/lib/plumb/interface_class.rb +21 -2
  35. data/lib/plumb/intersection.rb +47 -0
  36. data/lib/plumb/json_schema_visitor.rb +255 -36
  37. data/lib/plumb/key.rb +63 -13
  38. data/lib/plumb/mermaid_visitor.rb +129 -0
  39. data/lib/plumb/metadata.rb +10 -1
  40. data/lib/plumb/metadata_visitor.rb +36 -34
  41. data/lib/plumb/never_class.rb +38 -0
  42. data/lib/plumb/node_mapper.rb +97 -0
  43. data/lib/plumb/not.rb +34 -2
  44. data/lib/plumb/optimizer.rb +444 -0
  45. data/lib/plumb/or.rb +25 -23
  46. data/lib/plumb/pipeline.rb +99 -11
  47. data/lib/plumb/policy.rb +17 -4
  48. data/lib/plumb/range_class.rb +46 -0
  49. data/lib/plumb/relation.rb +57 -0
  50. data/lib/plumb/result.rb +55 -23
  51. data/lib/plumb/semantic_matcher.rb +393 -0
  52. data/lib/plumb/static_class.rb +20 -1
  53. data/lib/plumb/stream_class.rb +32 -6
  54. data/lib/plumb/subtyping.rb +461 -0
  55. data/lib/plumb/tagged_hash.rb +45 -4
  56. data/lib/plumb/tuple_class.rb +21 -4
  57. data/lib/plumb/type_cache.rb +41 -0
  58. data/lib/plumb/type_registry.rb +71 -0
  59. data/lib/plumb/typed_step.rb +67 -0
  60. data/lib/plumb/types.rb +27 -43
  61. data/lib/plumb/union.rb +30 -0
  62. data/lib/plumb/value_class.rb +20 -1
  63. data/lib/plumb/version.rb +1 -1
  64. data/lib/plumb/visitor_handlers.rb +20 -4
  65. data/lib/plumb.rb +90 -3
  66. metadata +30 -8
  67. data/lib/plumb/build.rb +0 -22
  68. data/lib/plumb/match_class.rb +0 -42
  69. data/lib/plumb/schema.rb +0 -195
  70. data/lib/plumb/step.rb +0 -27
  71. data/lib/plumb/transform.rb +0 -26
data/README.md CHANGED
@@ -139,7 +139,7 @@ More about [Types::Hash](#typeshash) and [Types::Array](#typesarray). There's al
139
139
 
140
140
  ### Type composition
141
141
 
142
- 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.
142
+ 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_ (`#not`) semantics, plus set-style _intersection_ (`#&`). Everything else builds on top of these ideas.
143
143
 
144
144
  #### Composing types with `#>>` ("And")
145
145
 
@@ -155,6 +155,43 @@ Similar to Ruby's built-in [function composition](https://thoughtbot.com/blog/pr
155
155
 
156
156
  In other words, `A >> B` means "if A succeeds, pass its result to B. Otherwise return A's failed result."
157
157
 
158
+ `#>>` also **type-checks the composition** at build time: if the left side could never produce a value the right side accepts, the chain is a dead end and it raises `Plumb::TypeError` before any data flows through. See [Composition type-checks](#composition-type-checks).
159
+
160
+ #### A plain callable between two types
161
+
162
+ A proc declares no types, so on its own it's an opaque step. Written *between* two types, those types move into its boundaries and the chain becomes a single typed function:
163
+
164
+ ```ruby
165
+ Doubled = Types::Integer >> ->(result) { result.valid(result.value * 2) } >> Types::Integer
166
+
167
+ Doubled.inspect # => "(Types::Integer -> Types::Integer)"
168
+ Doubled.input_type # => Types::Integer
169
+ Doubled.output_type # => Types::Integer
170
+ Doubled.parse(3) # => 6
171
+ Doubled.resolve('3').errors # => "Must be a Integer"
172
+ ```
173
+
174
+ The checks are the ones you wrote — the input validated before the callable runs, the output after — but they run as one node's boundaries instead of a three-step chain, and the result reports what it accepts and produces, so it keeps composing (and type-checking) downstream. Either half works on its own: `Types::Integer >> a_proc` is `(Types::Integer -> Plumb::Types::Any)`, typed on the side you declared.
175
+
176
+ Nothing is dropped to do this: a type only moves into a boundary the step left undeclared, so it runs exactly where the no-op ran. That includes a type that *builds* a value, so a struct pipeline collapses the same way:
177
+
178
+ ```ruby
179
+ Person = Types::Data[name: Types::String]
180
+ Renamer = Person >> ->(r) { r.valid(r.value.with(name: r.value.name.upcase)) } >> Person
181
+
182
+ Renamer.inspect # => "(Person -> Person)"
183
+ Renamer.parse(name: 'ada').name # => "ADA"
184
+ Renamer.resolve(name: 42).errors # => {name: "Must be a String"}
185
+ ```
186
+
187
+ What keeps its own node is a step that declares what it accepts, or one carrying its own callable — two of those meeting is transform fusion's business rather than absorption's:
188
+
189
+ ```ruby
190
+ # The transform declares String as its input, so the leading gate stays a step.
191
+ (Types::String >> Types::String.transform(::Integer, &:to_i)).inspect
192
+ # => "(Types::String >> (Types::String -> Integer))"
193
+ ```
194
+
158
195
  #### Disjunction with `#|` ("Or")
159
196
 
160
197
  `A | B` means "if A returns a valid result, return that. Otherwise try B with the original input."
@@ -214,6 +251,58 @@ FlexibleUSD.parse(Money.new(1000, 'GBP')) # Money(USD 15.00)
214
251
 
215
252
  You can see more use cases in [the examples directory](https://github.com/ismasan/plumb/tree/main/examples)
216
253
 
254
+ #### Intersection with `#&` and the `Never` type
255
+
256
+ `A & B` is the **intersection** (the greatest lower bound) of two types: it describes values that satisfy **both**. Unlike `#>>`, it is symmetric (order-independent) and never raises — where the two types can't overlap it produces `Types::Never` (see below).
257
+
258
+ Where it can, `#&` narrows to the exact overlap:
259
+
260
+ ```ruby
261
+ Types::Integer[2..] & Types::Integer[0..100] # => Integer[2..100] (ranges intersected)
262
+ Types::Integer[1, 2, 3] & Types::Integer[2, 3, 4] # => Integer[Set[2, 3]]
263
+ Types::Integer & Types::Numeric # => Integer (keeps the narrower)
264
+ ```
265
+
266
+ It distributes over unions and intersects covariant containers element-wise:
267
+
268
+ ```ruby
269
+ Types::Array[Types::Integer | Types::Float] & Types::Array[Types::Float] # => Array[Float]
270
+ Types::Integer | (Types::String & Types::Integer) # => Integer
271
+ ```
272
+
273
+ When the intersection is provably empty, the result is `Types::Never`:
274
+
275
+ ```ruby
276
+ Types::String & Types::Integer # => Types::Never (no value is both)
277
+ Types::Integer[2..10] & Types::Integer[11..100] # => Types::Never (disjoint ranges)
278
+ ```
279
+
280
+ Chaining refinements with `#[]` (or `#where`) is the same intersection, so a provably-empty chain reduces to `Types::Never` too:
281
+
282
+ ```ruby
283
+ Types::Integer[0..5][10..] # => Types::Never (== Integer[0..5] & Integer[10..])
284
+ Types::String.where(size: 0..5).where(size: 10..) # => Types::Never (unsatisfiable clause)
285
+ ```
286
+
287
+ When it can neither narrow nor prove emptiness, `#&` falls back to a runtime intersection that validates the value through both sides.
288
+
289
+ `Types::Hash#&` ([Hash intersections](#hash-intersections)) and `Types::Interface#&` ([Intersecting interfaces](#intersecting-interfaces)) are the record- and interface-specific cases of the same operator.
290
+
291
+ ##### `Types::Never`
292
+
293
+ `Types::Never` is the **bottom type** — the dual of the `Types::Any` top. No value inhabits it, so it always fails validation, and it collapses out of compositions:
294
+
295
+ ```ruby
296
+ Types::Any & Types::Integer # => Integer (Any is the identity of &)
297
+ Types::Integer & Types::Never # => Types::Never (Never absorbs &)
298
+ Types::Integer | Types::Never # => Integer (Never is dropped from |)
299
+
300
+ Types::Never.resolve(42).valid? # => false (nothing is a Never)
301
+ Types::Never.to_json_schema # => { "not" => {} }
302
+ ```
303
+
304
+ You rarely write `Types::Never` by hand — it's what an impossible intersection reduces to, which lets the composition algebra prove and discard dead branches (as in `Integer | (String & Integer)` above). It's also useful as a Hash catch-all to forbid undeclared keys — see [`_: Types::Never`](#undeclared-keys-and-the-_-catch-all).
305
+
217
306
  ### Built-in types
218
307
 
219
308
  * `Types::Value`
@@ -232,6 +321,7 @@ You can see more use cases in [the examples directory](https://github.com/ismasa
232
321
  * `Types::Numeric`
233
322
  * `Types::String`
234
323
  * `Types::Hash`
324
+ * `Types::Range`
235
325
  * `Types::SymbolizedHash`
236
326
  * `Types::UUID::V4`
237
327
  * `Types::Email`
@@ -243,15 +333,8 @@ You can see more use cases in [the examples directory](https://github.com/ismasa
243
333
  * `Types::Lax::Integer`
244
334
  * `Types::Lax::String`
245
335
  * `Types::Lax::Symbol`
246
- * `Types::Forms::Boolean`
247
- * `Types::Forms::Nil`
248
- * `Types::Forms::True`
249
- * `Types::Forms::False`
250
- * `Types::Forms::Date`
251
- * `Types::Forms::Time`
252
- * `Types::Forms::URI::Generic`
253
- * `Types::Forms::URI::HTTP`
254
- * `Types::Forms::URI::File`
336
+
337
+ For parsing stringy formats (HTML forms, query strings) into these types — what the `Types::Forms` namespace used to do, one way — see `Plumb::Codec::Forms` under [Encoders and Codecs](#encoders-and-codecs).
255
338
 
256
339
  TODO: datetime, others.
257
340
 
@@ -351,6 +434,14 @@ The helper accepts multiple attribute/value pairs
351
434
  JoeBloggs = Types::Any[User].where(first_name: 'Joe', last_name: 'Bloggs')
352
435
  ```
353
436
 
437
+ Attribute constraints take part in [composition type-checks](#composition-type-checks): a constrained type is a subtype of its base, and a constraint on the same attribute is a subtype when its value is contained in the other's (compared like ranges/literals).
438
+
439
+ ```ruby
440
+ Types::Array.where(size: 10) >> Types::Array # ok: constrained Array is still an Array
441
+ Types::Array.where(size: 10) >> Types::Array.where(size: 8..100) # ok: 10 is within 8..100
442
+ Types::Array.where(size: 10..15) >> Types::Array.where(size: 11..14) # raises: 10..15 isn't within 11..14
443
+ ```
444
+
354
445
  #### `#transform`
355
446
 
356
447
  Transform value. Requires specifying the resulting type of the value after transformation.
@@ -363,9 +454,27 @@ StringToInt = Types::String.transform(Integer, &:to_i)
363
454
  StringToInteger.parse('10') # => 10
364
455
  ```
365
456
 
457
+ As a shorthand, `#transform` also accepts a single Ruby conversion symbol — `:to_s`, `:to_sym`, `:to_i`, `:to_f`, `:to_r`, `:to_c`, `:to_a`, `:to_h`, `:to_proc` — and infers the output type from it:
458
+
459
+ ```ruby
460
+ Types::String.transform(:to_i) # transform to Integer, via #to_i
461
+ Types::Integer.transform(:to_s) # transform to String
462
+ # equivalent to
463
+ Types::String.transform(Integer, &:to_i)
464
+ ```
465
+
466
+ When the input's base Ruby type is known, it validates that the type actually responds to the method, so mistakes fail at build time:
467
+
468
+ ```ruby
469
+ Types::Integer.transform(:to_sym) # raises Plumb::TypeError (Integer has no #to_sym)
470
+ Types::Any.transform(:to_i) # ok — unknown input type, no check
471
+ ```
472
+
473
+ `#transform` builds a [`Plumb::Function`](#plumbfunctioninput--output) — the underlying typed-conversion node — with a block that takes and returns a plain value. To build one standalone from a callable, or to work at the `Result` level, use `Plumb::Function[]` directly.
474
+
366
475
  #### `#invoke`
367
476
 
368
- `#invoke` builds a Step that will invoke one or more methods on the value.
477
+ `#invoke` builds a step that will invoke one or more methods on the value.
369
478
 
370
479
  ```ruby
371
480
  StringToInt = Types::String.invoke(:to_i)
@@ -389,7 +498,7 @@ UpcaseToSym = Types::String.invoke(%i[downcase to_sym])
389
498
  UpcaseToSym.parse('FOO_BAR') # :foo_bar
390
499
  ```
391
500
 
392
- Note, as opposed to `#transform`, this helper does not register a type in `#metadata[:type]`, which can be valuable for introspection or documentation (ex. JSON Schema).
501
+ Note, as opposed to `#transform`, this helper does not declare a resulting output type (`#output_type`), which can be valuable for introspection or documentation (ex. JSON Schema).
393
502
 
394
503
  Also, there's no definition-time checks that the method names are actually supported by the input values.
395
504
 
@@ -504,7 +613,6 @@ ten.parse(10) # => 10
504
613
  ten.parse(100) # => 10
505
614
  ten.parse('hello') # => 10
506
615
  ten.parse() # => 10
507
- ten.metadata[:type] # => Integer
508
616
  ```
509
617
 
510
618
  Useful for data structures where some fields shouldn't change. Example:
@@ -517,12 +625,6 @@ CreateUserEvent = Types::Hash[
517
625
  ]
518
626
  ```
519
627
 
520
- Note that the value must be of the same type as the starting step's target type.
521
-
522
- ```ruby
523
- Types::Integer.static('nope') # raises ArgumentError
524
- ```
525
-
526
628
  This usage is similar as using `Types::Static['hello']`directly.
527
629
 
528
630
  This helper is shorthand for the following composition:
@@ -531,11 +633,11 @@ This helper is shorthand for the following composition:
531
633
  Types::Static[value] >> step
532
634
  ```
533
635
 
534
- This means that validations and coercions in the original step are still applied to the static value.
636
+ Because the static value flows through the original step's type, an inconsistent value is caught at build time by the [composition check](#composition-type-checks):
535
637
 
536
638
  ```ruby
537
- ten = Types::Integer[100..].static(10)
538
- ten.parse # => Plumb::ParseError "Must be within 100..."
639
+ Types::Integer[100..].static(10) # raises Plumb::TypeError (10 is not within 100..)
640
+ type = Types::Integer[100..].static(150) # ok
539
641
  ```
540
642
 
541
643
  So, normally you'd only use this attached to primitive types without further processing (but your use case may vary).
@@ -573,21 +675,109 @@ type.metadata[:description] # 'A long text'
573
675
  `#metadata` combines keys from type compositions.
574
676
 
575
677
  ```ruby
576
- type = Types::String.metadata(description: 'A long text') >> Types::String.match(/@/).metadata(note: 'An email address')
678
+ type = Types::String[/@/].metadata(note: 'An email address') >> Types::String.metadata(description: 'A long text')
577
679
  type.metadata[:description] # 'A long text'
578
680
  type.metadata[:note] # 'An email address'
579
681
  ```
580
682
 
581
- `#metadata` also computes the target type.
683
+ `#metadata` only carries user-provided annotations. The Ruby type(s) a composition accepts and produces are described by `#input_type` and `#output_type` instead (see below).
684
+
685
+ TODO: document custom visitors.
686
+
687
+ #### `#input_type` and `#output_type`
688
+
689
+ Every type exposes the type it expects as input and the type it produces as output.
690
+
691
+ ```ruby
692
+ StringToInt = Types::String.transform(Integer, &:to_i)
693
+ StringToInt.input_type # Types::String
694
+ StringToInt.output_type # Integer
695
+ ```
696
+
697
+ They resolve through a composition, reporting what the chain as a whole consumes and produces — not its individual steps. The steps themselves remain available as `#children`:
582
698
 
583
699
  ```ruby
584
- Types::String.metadata[:type] # String
585
- Types::String.transform(Integer, &:to_i).metadata[:type] # Integer
586
- # Multiple target types for unions
587
- (Types::String | Types::Integer).metadata[:type] # [String, Integer]
700
+ chain = Types::String.transform(Integer, &:to_i) >> Types::Integer.transform(Integer) { |i| i * 2 }
701
+ chain.input_type # Types::String the chain can only be called with a String
702
+ chain.output_type # Integer — it can only produce an Integer
703
+ chain.children # [(Types::String -> Integer), (Types::Integer -> Integer)]
588
704
  ```
589
705
 
590
- TODO: document custom visitors.
706
+ For a plain type, both are the type itself. Unions distribute over both sides:
707
+
708
+ ```ruby
709
+ (Types::String | Types::Integer).input_type # Types::String | Types::Integer
710
+ (Types::String | Types::Integer).output_type # Types::String | Types::Integer
711
+ ```
712
+
713
+ These power type introspection — for example, the JSON Schema visitor builds its schema from `#input_type`, since a schema describes the values a caller must send.
714
+
715
+ #### Composition type-checks
716
+
717
+ `#>>` is typed by **subsumption**, like function composition in a statically-typed language: everything the left step *produces* must be acceptable to the right step — i.e. the left's output type must be a **subtype** of the right's input type. If not, `#>>` raises `Plumb::TypeError` at build time, so broken data pipelines fail loudly when you define them, not silently at runtime.
718
+
719
+ ```ruby
720
+ Types::String >> Types::Integer # raises: String is not a subtype of Integer
721
+ Types::Numeric >> Types::Integer # raises: Numeric is broader than Integer
722
+ Types::Integer[0..40] >> Types::Integer[2..10] # raises: the left can emit values (0,1,11..40) the right rejects
723
+
724
+ Types::Integer >> Types::Numeric # ok: every Integer is a Numeric
725
+ Types::Integer[2..10] >> Types::Integer[0..40] # ok: 2..10 is within 0..40
726
+ ```
727
+
728
+ To **narrow** a value — where only some of what the left produces should pass — use `#[]` (or `#transform(...)[...]`). A refinement is a runtime-checked cast, built directly, and is *not* subject to the composition check:
729
+
730
+ ```ruby
731
+ Types::Integer[0..40][2..10] # narrow to 2..10 (runtime-checked)
732
+ Types::String.transform(Integer, &:to_i)[1..10] # convert, then bound the result
733
+ ```
734
+
735
+ For an arbitrary composition the checker can't prove — not just a matcher constraint — reach for `#/`, the unchecked counterpart of `#>>`. It builds the same refinement and is still validated at runtime, but skips the build-time check; you're asserting the chain is sound. It reads like `Pathname#/` ("join the next segment"):
736
+
737
+ ```ruby
738
+ Types::Integer / Types::Integer[2..10] # narrow without the build-time check
739
+ Types::String / Types::String[/@/] # a String you assert is an email downstream
740
+ ```
741
+
742
+ The check is permissive only where types are genuinely unknown: opaque steps (plain procs/lambdas, `#invoke`, `#generate`) and value-level transforms (`#transform`/`#build`) report `Any` on the relevant side and opt out. (`#static` ignores its input, so it never blocks a chain feeding *into* it, but it does declare the value it produces — so `Types::Static['foo'] >> Types::Integer` is flagged.)
743
+
744
+ For `Types::Hash` schemas, subsumption is record subtyping — the producer must provide every key the consumer requires (as a required key, with a subtype value); it may add extra keys:
745
+
746
+ ```ruby
747
+ # the consumer requires :age, but the producer never provides it:
748
+ Types::Hash[name: Types::String] >> Types::Hash[name: Types::String, age: Types::Integer]
749
+ # => Plumb::TypeError
750
+
751
+ # a shared key whose value type isn't a subtype:
752
+ Types::Hash[name: Types::String] >> Types::Hash[name: Types::Integer]
753
+ # => Plumb::TypeError
754
+
755
+ # ok — producer is a subtype of consumer (wider, with subtype values):
756
+ Types::Hash[name: Types::Integer, age: Types::Integer] >> Types::Hash[name: Types::Numeric]
757
+ ```
758
+
759
+ [`#where`](#where) attribute constraints subtype the same way — a constrained type is a subtype of its base, and a constraint is a subtype of a looser one on the same attribute:
760
+
761
+ ```ruby
762
+ Types::Array.where(size: 10) >> Types::Array.where(size: 8..100) # ok: 10 is within 8..100
763
+ Types::Array.where(size: 10..15) >> Types::Array.where(size: 11..14) # raises: 10..15 isn't within 11..14
764
+ ```
765
+
766
+ #### Subtype checks: `#<=` and `Plumb::Subtyping`
767
+
768
+ The relation behind the composition check is also available directly. `a <= b` asks "is every value described by `a` also described by `b`?" — i.e. is `a` a subtype/subset of `b`? — with `>=`, `<` and `>` derived from it. `Plumb::Subtyping.subtype?(a, b)` is the same check as a method. Built-in and custom types both participate, and raw Ruby classes/values are accepted on either side (they're normalized):
769
+
770
+ ```ruby
771
+ Types::Integer <= Types::Numeric # true
772
+ Types::Numeric <= Types::Integer # false
773
+ Types::String[/@/] <= Types::String # true (more refined => a subset)
774
+ Types::Integer <= Numeric # true (compares against a raw Ruby class)
775
+ Types::Array[Integer] <= Types::Array[Numeric] # true (covariant in the element type)
776
+
777
+ big = Types::Hash[name: Types::String, age: Types::Integer]
778
+ small = Types::Hash[name: Types::String]
779
+ big <= small # true (width + depth subtyping)
780
+ ```
591
781
 
592
782
  ### Other policies
593
783
 
@@ -630,7 +820,7 @@ Wraps a step's execution, rescues a specific exception and returns an invalid re
630
820
 
631
821
  Useful for turning a 3rd party library's exception into an invalid result that plays well with Plumb's type compositions.
632
822
 
633
- Example: this is how `Types::Forms::Date` uses the `:rescue` policy to parse strings with `Date.parse` and turn `Date::Error` exceptions into Plumb errors.
823
+ Example: parsing strings with `Date.parse` and turning `Date::Error` exceptions into Plumb errors.
634
824
 
635
825
  ```ruby
636
826
  # Accept a string that can be parsed into a Date
@@ -810,10 +1000,27 @@ StaffMember = User + Employee # Hash[:name, :age, :company]
810
1000
 
811
1001
  #### Hash intersections
812
1002
 
813
- Use `Types::Hash#&` to produce a new Hash definition with keys present in both.
1003
+ Use `Types::Hash#&` to intersect two hash definitions as maps. It keeps the keys present in **both**, and intersects each shared key's value type:
814
1004
 
815
1005
  ```ruby
816
- intersection = User & Employee # Hash[:name]
1006
+ User & Employee # => Hash[name: String] (only the shared :name survives)
1007
+
1008
+ # shared keys have their value types intersected
1009
+ Types::Hash[age: Types::Integer[18..]] & Types::Hash[age: Types::Integer[..65]]
1010
+ # => Hash[age: Integer[18..65]]
1011
+ ```
1012
+
1013
+ Two closed schemas that share no keys have nothing in common, so the intersection is `Types::Never` — the empty/bottom type, which no value satisfies:
1014
+
1015
+ ```ruby
1016
+ Types::Hash[a: Types::Integer] & Types::Hash[b: Types::String] # => Types::Never
1017
+ ```
1018
+
1019
+ A [`_` catch-all](#undeclared-keys-and-the-_-catch-all) widens what survives, since it admits the other side's extra keys:
1020
+
1021
+ ```ruby
1022
+ Types::Hash[a: Types::String, _: Types::Any] & Types::Hash[a: Types::String, b: Types::Integer]
1023
+ # => Hash[a: String, b: Integer] (:b admitted via the left's catch-all)
817
1024
  ```
818
1025
 
819
1026
  #### `Types::Hash#tagged_by`
@@ -833,18 +1040,41 @@ Events = Types::Hash.tagged_by(
833
1040
  Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition
834
1041
  ```
835
1042
 
836
- #### `Types::Hash#inclusive`
1043
+ #### Undeclared keys and the `_` catch-all
837
1044
 
838
- Use `#inclusive` to preserve input keys not defined in the hash schema.
1045
+ By default, keys present in the input but **not** declared in the schema are dropped:
839
1046
 
840
1047
  ```ruby
841
- hash = Types::Hash[age: Types::Lax::Integer].inclusive
1048
+ Types::Hash[age: Types::Integer].parse(age: 30, name: 'Joe') # => { age: 30 } (:name dropped)
1049
+ ```
1050
+
1051
+ To control what happens to those undeclared keys, add a special `_` key. It is a **catch-all**: its value type is applied to every key not otherwise declared. The value type you give it decides the behaviour:
1052
+
1053
+ | Catch-all | Meaning | Undeclared key `name`… |
1054
+ | --- | --- | --- |
1055
+ | _(none)_ | drop (default) | is removed |
1056
+ | `_: Types::Any` | include, unchanged | is kept as-is |
1057
+ | `_: SomeType` | include, validated/coerced | must be a `SomeType` (coerced if the type coerces) |
1058
+ | `_: Types::Never` | exclude (strict) | is a validation **error** |
1059
+
1060
+ ```ruby
1061
+ # _: Any — keep every undeclared key, unchanged
1062
+ hash = Types::Hash[age: Types::Lax::Integer, _: Types::Any]
1063
+ hash.parse(age: '30', name: 'Joe', last_name: 'Bloggs')
1064
+ # => { age: 30, name: 'Joe', last_name: 'Bloggs' }
1065
+
1066
+ # _: SomeType — every undeclared value must be (or coerce to) that type
1067
+ Types::Hash[id: Types::String, _: Types::Integer].parse(id: 'x', a: 1, b: 2)
1068
+ # => { id: 'x', a: 1, b: 2 }
1069
+ Types::Hash[id: Types::String, _: Types::Integer].resolve(id: 'x', a: 'nope').valid? # => false
842
1070
 
843
- # Only :age, is coerced and validated, all other keys are preserved as-is
844
- hash.parse(age: '30', name: 'Joe', last_name: 'Bloggs') # { age: 30, name: 'Joe', last_name: 'Bloggs' }
1071
+ # _: Never reject any undeclared key (a closed/strict hash)
1072
+ strict = Types::Hash[a: Types::String, _: Types::Never]
1073
+ strict.resolve(a: 'x').valid? # => true
1074
+ strict.resolve(a: 'x', b: 1).valid? # => false (b is not allowed)
845
1075
  ```
846
1076
 
847
- This can be useful if you only care about validating some fields, or to assemble different front and back hashes. For example a client-facing one that validates JSON or form data, and a backend one that runs further coercions or domain validations on some keys.
1077
+ `_: Any` is useful when you only care about validating some fields, or to assemble different front and back hashes for example a client-facing one that validates JSON or form data, and a backend one that runs further coercions on some keys while passing the rest through:
848
1078
 
849
1079
  ```ruby
850
1080
  # Front-end definition does structural validation
@@ -854,15 +1084,29 @@ Front = Types::Hash[price: Integer, name: String, category: String]
854
1084
  IntToMoney = Types::Integer.build(Money)
855
1085
 
856
1086
  # Backend definition turns :price into a Money object, leaves other keys as-is
857
- Back = Types::Hash[price: IntToMoney].inclusive
1087
+ Back = Types::Hash[price: IntToMoney, _: Types::Any]
858
1088
 
859
1089
  # Compose the pipeline
860
1090
  InputHandler = Front >> Back
861
1091
 
862
1092
  InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
863
- # => { price: #<Money fractional:100000 currency:GBP>, name: 'iPhone 15', category: 'smartphone' }
1093
+ # => { price: #<Money fractional:100000 currency:GBP>, name: 'iPhone 15', category: 'smartphones' }
864
1094
  ```
865
1095
 
1096
+ The catch-all also shows up in generated JSON Schema as `additionalProperties`: `_: Any` → `{}` (anything), `_: Integer` → `{ "type": "integer" }`, and `_: Never` → `{ "not": {} }` (nothing allowed).
1097
+
1098
+ #### Typed keys
1099
+
1100
+ Keys are not limited to symbols. A key can be any type or matcher, and it matches an input key via `key === other`. So you can key by String, or by a pattern, and mix them with a catch-all:
1101
+
1102
+ ```ruby
1103
+ Types::Hash['name' => Types::String] # a String key
1104
+ Types::Hash[Types::String[/^id_/] => Types::Integer, # keys matching /^id_/ hold Integers
1105
+ _: Types::Any] # everything else passes through
1106
+ ```
1107
+
1108
+ A typed key is **lenient**: input keys that don't match any declared or typed key follow the catch-all rule above (dropped by default). This is different from a homogeneous map (`Types::Hash[Types::Symbol, Types::Integer]`, a `HashMap` — note the comma, not `=>`), which is **strict** (a non-conforming key is an error) and coerces keys through the key type. Use a `HashMap` for "every key/value has this type"; use typed keys for "keys shaped like this map to that".
1109
+
866
1110
  #### `Types::Hash#filtered`
867
1111
 
868
1112
  The `#filtered` modifier returns a valid Hash with the subset of values that were valid, instead of failing the entire result if one or more values are invalid.
@@ -873,17 +1117,97 @@ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
873
1117
  User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
874
1118
  ```
875
1119
 
1120
+ ### `Types::Range`
1121
+
1122
+ `Types::Range` validates that a value is a Ruby `Range`. On its own it accepts any range:
1123
+
1124
+ ```ruby
1125
+ Types::Range.resolve(1..10) # valid
1126
+ Types::Range.resolve('a'..'z') # valid
1127
+ Types::Range.resolve(5) # invalid ("must be a Range")
1128
+ ```
1129
+
1130
+ Specialize it with `#[]` to constrain the range's endpoints. The member type is matched against both the range's `#begin` and `#end` (a `nil` bound — an open-ended range — is skipped):
1131
+
1132
+ ```ruby
1133
+ IntRange = Types::Range[Integer]
1134
+ IntRange.resolve(1..10) # valid
1135
+ IntRange.resolve('a'..'z') # invalid (endpoints aren't Integers)
1136
+ IntRange.resolve(1..) # valid (only the present bound is checked)
1137
+ ```
1138
+
1139
+ The member type is any `#===` interface, so a `Range` itself works as the member matcher to bound where the endpoints may fall:
1140
+
1141
+ ```ruby
1142
+ # A range whose endpoints both lie within 1..100
1143
+ Percent = Types::Range[1..100]
1144
+ Percent.resolve(10..20) # valid
1145
+ Percent.resolve(10..200) # invalid (200 is outside 1..100)
1146
+ ```
1147
+
1148
+ #### Open-ended ranges with `#where`
1149
+
1150
+ Use `#where` with the `begin`/`end` attributes to constrain the range's own endpoints. Passing `end: nil` matches only endless ranges, and `begin: nil` only beginless ranges:
1151
+
1152
+ ```ruby
1153
+ # Endless ranges only, eg. (1..)
1154
+ Endless = Types::Range[Integer].where(end: nil)
1155
+ Endless.resolve(1..) # valid
1156
+ Endless.resolve(1..10) # invalid ("must have attribute end === nil")
1157
+
1158
+ # Beginless ranges only, eg. (..10)
1159
+ Beginless = Types::Range[Integer].where(begin: nil)
1160
+ Beginless.resolve(..10) # valid
1161
+ Beginless.resolve(1..10) # invalid
1162
+ ```
1163
+
1164
+ `#where` values are also full `#===` matchers, so an endpoint can be constrained by a type or another range:
1165
+
1166
+ ```ruby
1167
+ # A range that starts at zero or above
1168
+ NonNegativeStart = Types::Range.where(begin: Types::Integer[0..])
1169
+ NonNegativeStart.resolve(5..10) # valid
1170
+ NonNegativeStart.resolve(-5..10) # invalid
1171
+ ```
1172
+
1173
+ #### Composition
1174
+
1175
+ `Types::Range` is covariant in its member type and preserves its input value (it validates endpoints without coercing them), so it composes like the other containers. A union absorbs a narrower member into a wider one:
1176
+
1177
+ ```ruby
1178
+ Types::Range[1..10] <= Types::Range[Integer] # true (covariant)
1179
+
1180
+ # The narrower branch is absorbed
1181
+ Types::Range[Integer] | Types::Range[1..10] # => Range[Integer]
1182
+ ```
1183
+
1184
+ #### JSON Schema
1185
+
1186
+ A `Types::Range` whose member pins numeric bounds maps to JSON Schema's native keywords, preserving an exclusive end as `exclusiveMaximum`:
1187
+
1188
+ ```ruby
1189
+ Plumb::JSONSchemaVisitor.call(Types::Range[0...100], root: false)
1190
+ # => { "type" => "integer", "minimum" => 0, "exclusiveMaximum" => 100 }
1191
+ ```
1192
+
876
1193
  ### `Types::SymbolizedHash`
877
1194
 
878
1195
  This type turns a hash's keys into symbols by calling `#to_sym` on them, and returning a new Hash.
879
1196
 
1197
+ `SymbolizedHash` is a `Symbol => Any` map. You can use it as a _transform_.
1198
+
880
1199
  ```ruby
881
- # Make sure to symbolize keys first
882
- type = Types::SymbolizedHash > Types::Hash[name: String, age: Integer]
883
- type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
1200
+ UserHash = Types::Hash[name: String]
1201
+ Types::SymbolizedHash.transform(UserHash).parse('name' => 'Joe') # { name: 'Joe' }
884
1202
  ```
885
1203
 
1204
+ You can also use the shortcut `#symbolized`
886
1205
 
1206
+ ```ruby
1207
+ # Symbolize keys, then coerce into a typed Hash.
1208
+ type = Types::Hash[name: String, age: Integer].symbolized
1209
+ type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
1210
+ ```
887
1211
 
888
1212
  ### maps
889
1213
 
@@ -947,6 +1271,47 @@ emails = Types::Array[Types::String[/@/]]
947
1271
 
948
1272
  Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching against the regular expression.
949
1273
 
1274
+ #### Chained array maps fuse into a single pass
1275
+
1276
+ `Types::Array` is covariant in its element type, so mapping `f` over an array and then mapping `g` is the same as mapping `f >> g` once. Composing two arrays applies that, and the collection is traversed once instead of twice:
1277
+
1278
+ ```ruby
1279
+ Trim = Types::String.transform(::String, &:strip)
1280
+ Downcase = Types::String.transform(::String, &:downcase)
1281
+ Symbolize = Types::String.transform(::Symbol, &:to_sym)
1282
+
1283
+ # Written as three separate maps over the collection...
1284
+ Tags = Types::Array[Trim] >> Types::Array[Downcase] >> Types::Array[Symbolize]
1285
+
1286
+ # ...built as one.
1287
+ Tags.class # => Plumb::ArrayClass
1288
+ Tags.inspect # => "Array[(Types::String -> Symbol)]"
1289
+ Tags == Types::Array[Trim >> Downcase >> Symbolize] # => true
1290
+
1291
+ Tags.parse([' RUBY ', ' Plumb', 'CSV ']) # => [:ruby, :plumb, :csv]
1292
+ ```
1293
+
1294
+ This is worth knowing when the element steps are defined apart from one another and only meet at a boundary — you get the single-pass version without hand-fusing it. On a 200-element array the three-stage chain above goes from 154.6µs to 102.2µs per value, and the two intermediate arrays are never built.
1295
+
1296
+ Validation is unaffected. The JSON Schema still describes the input side, and errors are still keyed by element index:
1297
+
1298
+ ```ruby
1299
+ Tags.to_json_schema # => {"type" => "array", "items" => {"type" => "string"}}
1300
+ Tags.resolve(['ok', 42, ' fine ']).errors # => {1 => "Must be a String"}
1301
+ ```
1302
+
1303
+ `Types::Tuple`, the `Types::Hash[K, V]` map form and `Types::Stream` fuse the same way. Fusion needs the element boundary to be provable — what the left element produces must be accepted by the right — so anything the checker can't prove is left as two passes:
1304
+
1305
+ ```ruby
1306
+ # Narrowing isn't provable, so this stays two passes (and `#>>` would reject it outright).
1307
+ Types::Array[Trim] / Types::Array[Types::String[/^a/]]
1308
+
1309
+ # Different containers, so no functor law to apply.
1310
+ Types::Array[Trim] / Types::Stream[Symbolize]
1311
+ ```
1312
+
1313
+ That guard is what keeps errors identical: two passes report stage by stage, so if the right map could reject what the left produced, one pass could surface errors two passes never reach. Records (`Types::Hash[name: ...]`) don't fuse either, since a record can drop, add and make keys optional.
1314
+
950
1315
  #### Concurrent arrays
951
1316
 
952
1317
  Use `Types::Array#concurrent` to process array elements concurrently (using Concurrent Ruby for now).
@@ -1323,7 +1688,7 @@ class DBConfig < Types::Data
1323
1688
  end
1324
1689
 
1325
1690
  class Config < Types::Data
1326
- attribute :host, Types::Forms::URI::HTTP, writer: true
1691
+ attribute :host, Plumb::Codec::HTTPURIEncoder, writer: true
1327
1692
  attribute :port, Types::Integer.default(80), writer: true
1328
1693
 
1329
1694
  # Nested structs can have writers too
@@ -1388,6 +1753,30 @@ result = CreateUser.resolve(name: 'Joe', age: 40)
1388
1753
  # result.value => User
1389
1754
  ```
1390
1755
 
1756
+ ##### `#step` (non-strict) and `#step!` (strict)
1757
+
1758
+ A pipeline is a sequence of validators/coercions that progressively narrows its data, so **`#step` is non-strict**: it chains with [`#/`](#composition-type-checks), skipping the composition check (a later step may legitimately narrow what an earlier one produced). Use **`#step!`** for the strict [`#>>` check](#composition-type-checks) — a build-time `Plumb::TypeError` if a step could never accept the previous step's output.
1759
+
1760
+ ```ruby
1761
+ pl.step Types::Hash # non-strict: a later step may narrow this
1762
+ pl.step! Types::Hash[name: Types::String] # strict: raises if it can't accept the prior output
1763
+ ```
1764
+
1765
+ To add a step that **transforms** the value into a new type, pass the output type followed by a block. This builds a [`#transform`](#transform) — a trusted, declared conversion — and the rest of the pipeline chains from the new type:
1766
+
1767
+ ```ruby
1768
+ User = Data.define(:name)
1769
+
1770
+ pipeline = Types::Any.pipeline do |pl|
1771
+ pl.step Types::Hash[name: Types::String]
1772
+ # output type + a block that produces it (the block takes and returns a Result)
1773
+ pl.step(User) { |result| result.valid(User.new(result.value[:name])) }
1774
+ end
1775
+
1776
+ pipeline.output_type == Plumb::Composable.wrap(User) # true
1777
+ pipeline.parse(name: 'Joe') # => #<data User name="Joe">
1778
+ ```
1779
+
1391
1780
  Pipelines are Plumb steps, so they can be composed further.
1392
1781
 
1393
1782
  ```ruby
@@ -1513,10 +1902,6 @@ Pipe2 = DebuggablePipeline.new do |pl|
1513
1902
  end
1514
1903
  ```
1515
1904
 
1516
- ### Plumb::Schema
1517
-
1518
- TODO
1519
-
1520
1905
  ### Recursive types
1521
1906
 
1522
1907
  You can use a proc to defer evaluation of recursive definitions.
@@ -1550,6 +1935,169 @@ LinkedList = Types::Hash[
1550
1935
 
1551
1936
 
1552
1937
 
1938
+ ### Encoders and Codecs
1939
+
1940
+ A one-way coercion can parse an external representation (a date string) into a parsed value (a `Date`), but not back. **Encoders** generalize that into pluggable, two-way serialization, and **Codecs** group encoders and apply them to whole schemas — Ruby data structures to JSON-ready structures and back, for example.
1941
+
1942
+ #### Defining encoders
1943
+
1944
+ An encoder is a class declaring an input and an output type, with `#decode` (input ⇒ output) and `#encode` (output ⇒ input) methods:
1945
+
1946
+ ```ruby
1947
+ DateRange = Types::Range[Types::Date]
1948
+ JSONDateRange = Types::Hash[from: Types::Date, to: Types::Date]
1949
+
1950
+ class JSONDateRangeEncoder < Plumb::Encoder[JSONDateRange => DateRange]
1951
+ def encode(range) = { from: range.begin, to: range.end }
1952
+ def decode(hash) = hash[:from]..hash[:to]
1953
+ end
1954
+ ```
1955
+
1956
+ By default an encoder behaves exactly like a transform in its declared direction (`JSONDateRange -> DateRange`, running `#decode`). But it is reversible: composed next to a type that matches its *output* side, it transparently runs the inverse.
1957
+
1958
+ ```ruby
1959
+ # Decode: the declared direction.
1960
+ FromJSON = JSONDateRange >> JSONDateRangeEncoder >> DateRange
1961
+ FromJSON.parse({ from: Date.new(2024, 1, 1), to: Date.new(2024, 2, 1) }) # => Date..Date range
1962
+
1963
+ # Encode: inferred from the DateRange on the left.
1964
+ ToJSON = DateRange >> JSONDateRangeEncoder >> JSONDateRange
1965
+ ToJSON.parse(Date.new(2024, 1, 1)..Date.new(2024, 2, 1)) # => { from: ..., to: ... }
1966
+ ```
1967
+
1968
+ Each direction is a normal Plumb step: it validates its input type, runs your method, and validates the produced value against its output type (a wrong return value is an invalid `Result`, and an exception raised inside `#encode`/`#decode` becomes an invalid `Result` too). Composition is type-checked as usual — `Types::Symbol >> JSONDateRangeEncoder` raises `Plumb::TypeError`.
1969
+
1970
+ Where the context gives no signal — schema literals (`Types::Hash[dates: SomeEncoder]`), `#/`, `.parse`, or an `Any`/opaque neighbour — the declared direction is used. `.decoding` (the declared direction) and `.encoding` (the inverse, input/output swapped) are the explicit forms:
1971
+
1972
+ ```ruby
1973
+ JSONDateRangeEncoder.decoding # JSONDateRange -> DateRange, runs #decode
1974
+ JSONDateRangeEncoder.encoding # DateRange -> JSONDateRange, runs #encode
1975
+
1976
+ JSONDateRangeEncoder.decode(from: Date.new(2024, 1, 1), to: Date.new(2024, 2, 1)) # => a Range
1977
+ JSONDateRangeEncoder.encode(Date.new(2024, 1, 1)..Date.new(2024, 2, 1)) # => a Hash
1978
+ ```
1979
+
1980
+ Encoders also express lenient unions — `Types::Date | SomeDateEncoder` accepts a `Date` or decodes a string into one.
1981
+
1982
+ #### Codecs
1983
+
1984
+ A codec groups encoders and applies them to whole types at composition time. Codecs know nothing about any particular format — only their encoders. Types that are already valid in the target format are declared with `.noop`:
1985
+
1986
+ ```ruby
1987
+ # Plumb::Codec::JSON ships noops for String, Numeric, booleans, Nil and bare
1988
+ # Hash/Array, plus built-in string encoders for Dates, Times, URIs,
1989
+ # Symbols and Decimals.
1990
+ class JSONCodec < Plumb::Codec::JSON
1991
+ encoder JSONDateRangeEncoder
1992
+ end
1993
+ ```
1994
+
1995
+ (A subclass encoder registered for an equivalent type — eg. your own Date encoder — takes precedence over an inherited built-in.)
1996
+
1997
+ Composing a codec with a type rewrites the type deeply, in either direction:
1998
+
1999
+ ```ruby
2000
+ Person = Types::Hash[name: Types::String, dates: DateRange]
2001
+
2002
+ JSONPerson = JSONCodec >> Person # decode: JSON structures -> Person
2003
+ JSONPerson.parse({ name: 'Joe', dates: { from: '2024-01-01', to: '2024-02-01' } })
2004
+ # => { name: 'Joe', dates: Date(2024-01-01)..Date(2024-02-01) }
2005
+
2006
+ EncodedPerson = Person >> JSONCodec # encode: Person -> JSON structures
2007
+ EncodedPerson.parse({ name: 'Joe', dates: Date.new(2024, 1, 1)..Date.new(2024, 2, 1) })
2008
+ # => { name: 'Joe', dates: { from: '2024-01-01', to: '2024-02-01' } }
2009
+ ```
2010
+
2011
+ `Codec.for(type)` returns both directions as a `[decoding, encoding]` pair:
2012
+
2013
+ ```ruby
2014
+ decoder, encoder = JSONCodec.for(Person)
2015
+ decoder.parse(json_data) # => a Person hash
2016
+ encoder.parse(person_hash) # => JSON structures
2017
+ ```
2018
+
2019
+ Note how the `Date` values *inside* `JSONDateRange` were resolved too: an encoder's input type is itself rewritten through the same codec, so nested non-native values are handled by other encoders in the group (here, the built-in `Date` encoder). The rewrite recurses into nested hashes, arrays, tuples, hash maps, union branches, metadata/policy wrappers and `.defer`red recursive types. Matching is by subtyping against each encoder's output type, most-specific encoder first.
2020
+
2021
+ Codecs work with any type, not just schemas:
2022
+
2023
+ ```ruby
2024
+ (JSONCodec >> Types::Date).parse('2024-01-01') # => Date
2025
+ (Types::Date >> JSONCodec).parse(Date.new(2024, 1, 1)) # => '2024-01-01'
2026
+ JSONCodec >> Types::String # => Types::String, unchanged (noop)
2027
+ ```
2028
+
2029
+ Struct classes (`Types::Data` subclasses, or any class that `include`s `Plumb::Attributes`) work too, at any depth — decoding builds instances, encoding takes them apart:
2030
+
2031
+ ```ruby
2032
+ class Company < Types::Data
2033
+ attribute :name, Types::String
2034
+ attribute :founded, Types::Date
2035
+ end
2036
+
2037
+ decoder, encoder = JSONCodec.for(Company)
2038
+ company = decoder.parse({ name: 'ACME', founded: '2024-01-01' }) # => #<Company founded: Date>
2039
+ encoder.parse(company) # => { name: 'ACME', founded: '2024-01-01' }
2040
+ ```
2041
+
2042
+ A field whose type is a **converting step** — a `#transform`/`#build` Function, a [`Plumb::Implementation`](#include-plumbimplementationinput--output-to-declare-a-class-types), a struct class — is decoded by rewriting what it *accepts* and putting that in front of it, so the step is fed the decoded value. A `Types::Data` class is one such node (`Hash[…] -> Person`); so is a hand-written equivalent, and both are handled the same way:
2043
+
2044
+ ```ruby
2045
+ class ParseRecord
2046
+ extend Plumb::Implementation[Types::Hash[on: Types::Date] => Record]
2047
+
2048
+ def self._call(result) = result.valid(Record.new(result.value[:on]))
2049
+ end
2050
+
2051
+ (JSONCodec >> ParseRecord).parse({ on: '2024-01-01' }) # => #<Record on: Date>
2052
+ ```
2053
+
2054
+ Its accepted `Date` is decoded from a string first; the step itself is preserved and still validates what it is handed. A step whose accepted type is already native is left untouched; one whose accepted type the codec can't decode raises, naming the step.
2055
+
2056
+ A field that matches no encoder and no noop is a composition-time error naming the field path:
2057
+
2058
+ ```ruby
2059
+ JSONCodec >> Types::Hash[profile: Types::Hash[joined: Types::Any[Time]]]
2060
+ # raises Plumb::TypeError: ... field `profile.joined` (Any[Time]) matches no encoder ...
2061
+ ```
2062
+
2063
+ The result of a codec composition is ordinary Plumb algebra — the codec leaves no runtime node behind — so JSON Schema generation works, describing the input side of a decoded schema:
2064
+
2065
+ ```ruby
2066
+ JSONPerson.to_json_schema
2067
+ # "dates" is described as { "type" => "object", "properties" => { "from" => { "type" => "string" }, ... } }
2068
+ ```
2069
+
2070
+ #### `Codec::Forms`: string-based formats
2071
+
2072
+ The second built-in codec targets HTML forms, query strings and other formats where **every value arrives as a string**. Unlike `Codec::JSON` there are almost no native scalars: strings pass through, untyped containers recurse (Rack-style nested params), and everything else maps through an encoder with a strictly-patterned string input type — integers (`/\A-?\d+\z/`), floats, decimals, booleans (`"true"/"1"`, `"false"/"0"`, case-insensitive), ISO 8601 dates and times, scheme-prefixed URIs, and the empty string for `nil` (so `Types::Date | Types::Nil` decodes `''` to `nil`).
2073
+
2074
+ ```ruby
2075
+ Config = Types::Hash[
2076
+ host: Types::URI::HTTP,
2077
+ port: Types::Integer,
2078
+ active: Types::Boolean,
2079
+ starts_on: Types::Date | Types::Nil
2080
+ ]
2081
+
2082
+ decoder, encoder = Plumb::Codec::Forms.for(Config)
2083
+ decoder.parse({ host: 'http://example.com', port: '80', active: '1', starts_on: '' })
2084
+ # => { host: URI(...), port: 80, active: true, starts_on: nil }
2085
+ encoder.parse({ host: URI.parse('http://example.com'), port: 80, active: true, starts_on: nil })
2086
+ # => { host: 'http://example.com', port: '80', active: 'true', starts_on: '' }
2087
+ ```
2088
+
2089
+ `Codec::Forms` replaces the old one-way `Types::Forms` namespace. The input types are strict — actual integers or booleans are *not* accepted on decode, since form data is always strings; apply the codec at the boundary and write schemas in output types.
2090
+
2091
+ Format-neutral encoders live at the `Plumb::Codec` level and are registered by both built-in codecs: ISO 8601 `Codec::DateEncoder`/`Codec::TimeEncoder`, RFC 3986 `Codec::URIEncoder`/`HTTPURIEncoder`/`FileURIEncoder`, `Codec::SymbolEncoder` (Symbols travel as strings) and `Codec::DecimalEncoder` (BigDecimals travel as canonical decimal strings — a string, not a number, to keep their precision; this also applies under `Codec::JSON`, where a raw BigDecimal would not be JSON-native). They are also usable per-field (`attribute :host, Plumb::Codec::HTTPURIEncoder`), and the old lenient behaviour is expressible as a union: `Types::Date | Plumb::Codec::DateEncoder`.
2092
+
2093
+ Things to know:
2094
+
2095
+ * Direction inference needs a typed neighbour. Opaque contexts fall back to the declared direction — use `.decoding`/`.encoding` to be explicit.
2096
+ * The JSON Schema of an *encode* pipeline describes what it accepts (its output-typed values), per the library convention that schemas describe accepted inputs. Visit the decode direction for the input-format schema.
2097
+ * `.defer`red fields rewrite lazily, so an unmatched type inside one surfaces at first resolution rather than at composition.
2098
+ * Registering `noop Types::Hash` / `Types::Array` only covers *untyped* containers — structured schemas (and struct classes) are always recursed into, so a generic noop can't accidentally skip encoding of nested fields.
2099
+ * Decoding a struct runs the rewritten schema and then the struct's own validation — correct, but a struct attribute with a non-idempotent transform would apply it twice. Struct attributes should be validators/coercions, as they already must be for `#with`.
2100
+
1553
2101
  ### Custom types
1554
2102
 
1555
2103
  Every Plumb type exposes the following one-method interface:
@@ -1564,25 +2112,62 @@ The `Result::Valid` class has helper methods `#valid(value) => Result::Valid` an
1564
2112
 
1565
2113
  #### Compose procs or lambdas directly
1566
2114
 
1567
- Piping any `#call` object onto Plumb types will wrap your object in a `Plumb::Step` with all methods necessary for further composition.
2115
+ Piping any `#call` object onto Plumb types wraps your object in a composable step, with all methods necessary for further composition.
1568
2116
 
1569
2117
  ```ruby
1570
2118
  Greeting = Types::String >> ->(result) { result.valid("Hello #{result.value}") }
1571
2119
  ```
1572
2120
 
1573
- #### Wrap a `#call` object in `Plumb::Step` explicitely
2121
+ #### `Plumb::Function[input => output]`
2122
+
2123
+ To build a standalone, typed function from a callable — one not already piped onto a type — use `Plumb::Function[]`. Declaring both ends gives you a typed function: the input is validated before your callable runs, and the value it produces is validated against the output type.
2124
+
2125
+ ```ruby
2126
+ Greeting = Plumb::Function[String => String] do |result|
2127
+ result.valid("Hello #{result.value}")
2128
+ end
2129
+
2130
+ Greeting.parse('Joe') # => 'Hello Joe'
2131
+ Greeting.parse(10) # raises Plumb::ParseError ("Must be a String")
2132
+ ```
2133
+
2134
+ The block takes and returns a [`Result`](#custom-types) — unlike [`#transform`](#transform), whose block takes and returns a plain value. A callable can be passed instead of a block:
2135
+
2136
+ ```ruby
2137
+ Greeting = Plumb::Function[MyGreeter.new, String => String]
2138
+ ```
2139
+
2140
+ Because both ends are declared, the resulting step takes part in [composition type checks](#composition-type-checks) and JSON Schema generation, just like `#transform`:
1574
2141
 
1575
- You can also wrap a proc in `Plumb::Step` explicitly.
2142
+ ```ruby
2143
+ StringLength = Plumb::Function[String => Integer] { |result| result.valid(result.value.size) }
2144
+ StringLength.input_type # => String
2145
+ StringLength.output_type # => Integer
2146
+
2147
+ Types::Integer >> StringLength # raises Plumb::TypeError at build time
2148
+ ```
2149
+
2150
+ You can also pass a custom `#call(Result) => Result` interface as the first argument, to turn a callable into a typed function.
1576
2151
 
1577
2152
  ```ruby
1578
- Greeting = Plumb::Step.new do |result|
2153
+ TypedGreeting = Plumb::Function[Greeting.new('Mr.'), String => String]
2154
+ TypedGreeting.parse('Joe') # "Mr. Joe"
2155
+ TypedGreeting.parse(10) # raises Plumb::ParseError
2156
+ ```
2157
+
2158
+
2159
+
2160
+ Omit the types when the callable is genuinely untyped. Both ends default to `Types::Any`, and the step opts out of composition checks.
2161
+
2162
+ ```ruby
2163
+ Greeting = Plumb::Function[] do |result|
1579
2164
  result.valid("Hello #{result.value}")
1580
2165
  end
1581
2166
  ```
1582
2167
 
1583
- Note that this example is not prefixed by `Types::String`, so it doesn't first validate that the input is indeed a string.
2168
+ Note that this last example doesn't validate that the input is indeed a String, whereas `Plumb::Function[String => String]` does.
1584
2169
 
1585
- However, this means that `Greeting` is a `Plumb::Step` which comes with all the Plumb methods and policies.
2170
+ Either way, `Greeting` is a full Plumb step, which comes with all the Plumb methods and policies.
1586
2171
 
1587
2172
  ```ruby
1588
2173
  # Greeting responds to #>>, #|, #default, #transform, etc etc
@@ -1599,11 +2184,11 @@ class Greeting
1599
2184
  @gr = gr
1600
2185
  end
1601
2186
 
1602
- # The Plumb Step interface
2187
+ # The Plumb step interface
1603
2188
  # @param result [Plumb::Result::Valid]
1604
2189
  # @return [Plumb::Result::Valid, Plumb::Result::Invalid]
1605
2190
  def call(result)
1606
- result.valid("#{gr} #{result.value}")
2191
+ result.valid("#{@gr} #{result.value}")
1607
2192
  end
1608
2193
  end
1609
2194
 
@@ -1614,7 +2199,7 @@ This is useful when you want to parameterize your custom steps, for example by i
1614
2199
 
1615
2200
  #### Include `Plumb::Composable` to make instance of a class full "steps"
1616
2201
 
1617
- The class above will be wrapped by `Plumb::Step` when piped into other steps, but it doesn't support Plumb methods on its own.
2202
+ The class above will be wrapped in a composable step when piped into other steps, but it doesn't support Plumb methods on its own.
1618
2203
 
1619
2204
  Including `Plumb::Composable` makes it support all Plumb methods directly.
1620
2205
 
@@ -1628,9 +2213,9 @@ class Greeting
1628
2213
  @gr = gr
1629
2214
  end
1630
2215
 
1631
- # The Step interface
2216
+ # The step interface
1632
2217
  def call(result)
1633
- result.valid("#{gr} #{result.value}")
2218
+ result.valid("#{@gr} #{result.value}")
1634
2219
  end
1635
2220
 
1636
2221
  # This is optional, but it allows you to control your object's #inspect
@@ -1650,15 +2235,149 @@ LoudGreeting = Greeting.new('Hola').default('no greeting').invoke(:upcase)
1650
2235
  class User
1651
2236
  extend Composable
1652
2237
 
1653
- def self.class(result)
2238
+ def self.call(result)
1654
2239
  # do something here. Perhaps returning a Result with an instance of this class
1655
- return result.valid(new)
2240
+ result.valid(new)
1656
2241
  end
1657
2242
  end
1658
2243
  ```
1659
2244
 
1660
2245
  This is how [Plumb::Types::Data](#typesdata) is implemented.
1661
2246
 
2247
+ #### Include `Plumb::Implementation[input => output]` to declare a class' types
2248
+
2249
+ `Plumb::Composable` makes your instances composable, but Plumb knows nothing about what they accept or produce — they're opaque, so they opt out of [composition type-checks](#composition-type-checks) and subtype checks.
2250
+
2251
+ `Plumb::Implementation[Input => Output]` is `Composable` plus a declared type pair. It makes your instances behave like a [`Plumb::Function`](#plumbfunctioninput--output): your class owns its `#initialize` and its state, and implements a private `#_call(Result) => Result`.
2252
+
2253
+ The mixin owns the public `#call`, which runs the declared checks around your `#_call`:
2254
+
2255
+ ```
2256
+ result.map(input_type).map(_call).map(output_type)
2257
+ ```
2258
+
2259
+ ie. the input is validated (and coerced, if the input type converts) before `#_call` sees it, and what it returns is validated against the output type.
2260
+
2261
+ ```ruby
2262
+ class UserFinder
2263
+ include Plumb::Implementation[Types::UUID::V4 => User]
2264
+
2265
+ def initialize(user_scope)
2266
+ @user_scope = user_scope
2267
+ end
2268
+
2269
+ private def _call(result)
2270
+ user = User.where(level: @user_scope).find_by(id: result.value)
2271
+ return result.invalid(errors: 'no user!') unless user
2272
+
2273
+ result.valid(user)
2274
+ end
2275
+ end
2276
+ ```
2277
+
2278
+ Instances are now fully typed steps:
2279
+
2280
+ ```ruby
2281
+ finder = UserFinder.new('admin')
2282
+
2283
+ finder.parse(some_uuid) # => a User. Raises Plumb::ParseError unless the input is a UUID
2284
+ finder >> some_other_step # composition, type-checked at build time
2285
+ Types::UUID::V4 >> finder # ...on both sides
2286
+ Types::Integer >> finder # => Plumb::TypeError: Integer is not a subtype of UUID::V4
2287
+
2288
+ finder <= User # => true. Like a Function, it is identified by what it PRODUCES
2289
+ finder.to_json_schema # describes the INPUT side, like any other conversion
2290
+
2291
+ Types::Hash[user: finder] # use it anywhere a type is expected
2292
+ ```
2293
+
2294
+ Both sides are wrapped with `Plumb::Composable.wrap`, so raw Ruby classes and hash literals work too: `Plumb::Implementation[{id: Types::String} => User]`.
2295
+
2296
+ Instances report `#node_name` `:function`, so every visitor, JSON Schema handler and policy that understands a conversion node understands yours. Define your own `#node_name` after the include if you have visitors of your own. Everything else is the [regular extension surface](#participating-in-subtype--composition-checks): override `#subtype_of?`, `#value_preserving?` etc. as needed.
2297
+
2298
+ `include Plumb::Implementation` with no pair declares `Any => Any` — the opaque case, equivalent to `Plumb::Function.opaque`.
2299
+
2300
+ Subclassing needs no ceremony: `#_call` is an ordinary method, so an override is found by normal lookup and the inherited `#call` keeps checking around it. `super` reaches the parent's `#_call` directly, with no repeated checks.
2301
+
2302
+ ```ruby
2303
+ class AdminFinder < UserFinder
2304
+ # input already validated as a UUID; the User you return is still checked
2305
+ private def _call(result) = result.valid(super.value.becomes(Admin))
2306
+ end
2307
+ ```
2308
+
2309
+ #### Extend `Plumb::Implementation[input => output]` to make the class itself a typed step
2310
+
2311
+ Just as with [`Plumb::Composable`](#extend-a-class-with-plumbcomposable-to-make-the-class-itself-a-composable-step), `extend` instead of `include` puts the whole interface on the class: no instantiation, the class implements `self._call(result)` and answers `.input_type` / `.output_type`.
2312
+
2313
+ ```ruby
2314
+ class ParseUUID
2315
+ extend Plumb::Implementation[Types::String => Types::UUID::V4]
2316
+
2317
+ def self._call(result) = result.valid(result.value.downcase)
2318
+ end
2319
+
2320
+ ParseUUID.parse('E1D3...') # the class IS the step
2321
+ ParseUUID >> UserFinder.new('admin') # composes like any other type
2322
+ Types::Hash[id: ParseUUID]
2323
+ ParseUUID.to_json_schema
2324
+ ```
2325
+
2326
+ The two forms are alternatives — pick one per class. The extended form deliberately does **not** take over the class' own `#name`, `#inspect`, `#==` or `#<=` (on a class, `<=` means Ruby module ancestry), so ask for the subtype relation explicitly instead:
2327
+
2328
+ ```ruby
2329
+ Plumb::Subtyping.subtype?(ParseUUID, Types::String) # => true
2330
+ ```
2331
+
2332
+ #### Participating in subtype & composition checks
2333
+
2334
+ The subtype (`#<=`) and [`#>>` composition](#composition-type-checks) checks are built on a single hook that every `Plumb::Composable` already implements with a sensible default — `#>>` is just `subtype?(produced, accepted)`, so there's nothing extra to implement for composition. A custom type participates **without changing any core library code**: it either relies on the default or overrides the hook. `Plumb::Subtyping` itself only knows the composition algebra (the top type `Types::Any`, the bottom type `Types::Never`, union `#|`, intersection `#&`, refinement/sequencing `#>>`, and conversion `#transform`); everything else is delegated to the type.
2335
+
2336
+ The default leans on two methods your type already has:
2337
+
2338
+ - `#children` — the sub-types this type is built from, as an array. A type whose single child is a **raw Ruby matcher** (a Class, Range, Regexp or literal — as `Plumb::Constraint` wraps) is treated as *atomic* and compared with Ruby semantics. A type whose children are themselves Plumb types (like `Array`, `Tuple`, `HashMap`) is treated as a **covariant container** — so exposing `#children` is all a custom container needs to compare covariantly.
2339
+ - `#==` — structural equality (provided by `Plumb::Composable`).
2340
+
2341
+ ##### The hook
2342
+
2343
+ | Hook | Returns | Used by | Default |
2344
+ | --- | --- | --- | --- |
2345
+ | `#subtype_of?(other)` | `Boolean` | `#<=`, `Plumb::Subtyping.subtype?`, and so `#>>` | reflexive · atomic · same-class covariant `#children` |
2346
+
2347
+ `#subtype_of?` answers "is every value I describe also described by `other`?". It's the leaf step of `subtype?`, reached after the algebra (`Any`/`Never`/`|`/`&`/`>>`/`#transform`) has been peeled away. Override it for bespoke behaviour — **recurse through `Plumb::Subtyping.subtype?`, never through `#<=`** (which would loop back into the algebra). `HashClass` overrides it for record (width + depth + optionality) subtyping.
2348
+
2349
+ ```ruby
2350
+ # An "even integer" refinement that knows it is a subtype of Integer (and
2351
+ # therefore Numeric), and defers everything else to the default.
2352
+ class EvenInteger
2353
+ include Plumb::Composable
2354
+
2355
+ def call(result)
2356
+ result.value.is_a?(::Integer) && result.value.even? ? result : result.invalid(errors: 'must be even')
2357
+ end
2358
+
2359
+ def subtype_of?(other)
2360
+ Plumb::Subtyping.subtype?(Types::Integer, other) || super
2361
+ end
2362
+
2363
+ private def _inspect = 'EvenInteger'
2364
+ end
2365
+
2366
+ even = EvenInteger.new
2367
+ even <= Types::Integer # => true
2368
+ even <= Types::Numeric # => true
2369
+ even <= Types::String # => false
2370
+ ```
2371
+
2372
+ ##### Type flow: `#input_type` / `#output_type`
2373
+
2374
+ The `#>>` check (and the [JSON Schema visitor](#json-schema)) ask what a type accepts and produces; both [default to `self`](#input_type-and-output_type). Override them when your type changes the value or is opaque about it:
2375
+
2376
+ - a value-converting step declares a different `#output_type` (what `#transform`/`#build` do via `Plumb::Function`);
2377
+ - an opaque step (a wrapped proc, a generator) returns `Types::Any` for both, opting out of the `#>>` compatibility check.
2378
+
2379
+ Custom types are **values/leaves** in the algebra — you compose them with the built-in combinators (`#>>`, `#|`, `#transform`, `Types::Any`) rather than re-implementing those.
2380
+
1662
2381
  ### Custom policies
1663
2382
 
1664
2383
  `Plumb.policy` can be used to encapsulate common type compositions, or compositions that can be configurable by parameters.
@@ -1709,7 +2428,7 @@ end
1709
2428
 
1710
2429
  # Usage: annotate fields in a schema
1711
2430
  AccountName = Types::String.admin
1712
- AccountName.metadata # => { type: String, admin: true }
2431
+ AccountName.metadata # => { admin: true }
1713
2432
  ```
1714
2433
 
1715
2434
  #### Type-specific policies
@@ -1767,6 +2486,13 @@ Plumb.policy :split, SplitPolicy
1767
2486
 
1768
2487
  Plumb ships with a JSON schema visitor that compiles a type composition into a JSON Schema Hash. All Plumb types support a `#to_json_schema` method.
1769
2488
 
2489
+ The generated schema describes the **input** a type accepts (its `#input_type`), not what it produces. So a coercing type advertises the type a caller should send:
2490
+
2491
+ ```ruby
2492
+ # Accepts a String, coerces it to an Integer
2493
+ Types::String.transform(Integer, &:to_i).to_json_schema # => { "type" => "string" }
2494
+ ```
2495
+
1770
2496
  ```ruby
1771
2497
  Payload = Types::Hash[name: String]
1772
2498
  Payload.to_json_schema(root: true)
@@ -1826,12 +2552,57 @@ Types::DateTime.to_json_schema
1826
2552
  # {"type"=>"string", "format"=>"date-time"}
1827
2553
  ```
1828
2554
 
2555
+ ##### Node names for compositions
2556
+
2557
+ Two-sided compositions report one of four `#node_name`s, depending on whether the node is a *computation* (some side changes the value) or a *type* (no side does):
2558
+
2559
+ | Node name | Built by | Meaning |
2560
+ | --------------- | --------------------------------- | ----------------------------------------------------------- |
2561
+ | `:and` | `#>>` with a converting side | Sequential composition — consumes the left's input, produces the right's output |
2562
+ | `:intersection` | `#>>`, `#/`, `#where`, `#check`, `#&` | The meet — both sides constrain the *same* value |
2563
+ | `:or` | `#\|` with a converting branch | Left-biased choice — a branch may coerce, so the ends differ |
2564
+ | `:union` | `#\|` with value-preserving branches | The join — a plain set of alternatives |
2565
+
2566
+ For a visitor this matters because an `:intersection` describes one value (merge both sides' specs) while an `:and` may describe a conversion (build from the input side). Visitors that don't need the distinction can register just `on(:and)` / `on(:or)`: `:intersection` and `:union` fall back to those when no specific handler is defined.
2567
+
2568
+ ### Mermaid diagrams
2569
+
2570
+ Because a composition is just a tree of `>>` (sequence) and `|` (choice) nodes, it can also be rendered as a [Mermaid](https://mermaid.js.org) `flowchart`. Every Plumb type supports `#to_mermaid`. `>>` becomes sequential arrows; `|` becomes a fork, where the preceding step fans out to each alternative (and a following step joins them back).
2571
+
2572
+ ```ruby
2573
+ type = (A >> B) | (C >> (D | B))
2574
+ puts type.to_mermaid
2575
+ ```
2576
+
2577
+ ```mermaid
2578
+ flowchart LR
2579
+ start(( ))
2580
+ n1["A"]
2581
+ n2["B"]
2582
+ n3["C"]
2583
+ n4["D"]
2584
+ n5["B"]
2585
+ start --> n1
2586
+ start --> n3
2587
+ n1 --> n2
2588
+ n3 --> n4
2589
+ n3 --> n5
2590
+ ```
2591
+
2592
+ Each box is labelled by the node's metadata `:title` (or `:label`) when present, otherwise by its `#inspect` — so constant-bound types show their constant name. Structural nodes (`>>`, `|`) shape the graph; every other type (steps, transforms, refinements, hashes, arrays, …) renders as a single opaque box. Recursive types (`#defer`) render as one box rather than recursing forever.
2593
+
2594
+ The direction is configurable, and the visitor can be used directly:
2595
+
2596
+ ```ruby
2597
+ type.to_mermaid(direction: 'TB')
2598
+ Plumb::MermaidVisitor.call(type)
2599
+ ```
2600
+
1829
2601
 
1830
2602
 
1831
2603
  ## TODO:
1832
2604
 
1833
2605
  - [ ] benchmarks and performace. Compare with `Parametric`, `ActiveModel::Attributes`, `ActionController::StrongParameters`
1834
- - [ ] flesh out `Plumb::Schema`
1835
2606
  - [x] `Plumb::Struct`
1836
2607
  - [x] flesh out and document `Plumb::Pipeline`
1837
2608
  - [ ] document custom visitors