plumb 0.0.18 → 0.2.0.beta.2

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 +887 -64
  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 +133 -25
  19. data/lib/plumb/attribute_value_match.rb +41 -1
  20. data/lib/plumb/attributes.rb +59 -19
  21. data/lib/plumb/codec.rb +886 -0
  22. data/lib/plumb/composable.rb +451 -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 +339 -32
  32. data/lib/plumb/hash_map.rb +58 -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 +26 -29
  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 +28 -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 +44 -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
 
@@ -410,6 +519,27 @@ str.parse() # 'nope'
410
519
  str.parse('yup') # 'yup'
411
520
  ```
412
521
 
522
+ A block generates the value on every invocation, instead of returning a fixed one:
523
+
524
+ ```ruby
525
+ id = Types::UUID::V4.default { SecureRandom.uuid }
526
+ id.parse() # a fresh UUID each time
527
+ ```
528
+
529
+ The step _declares_ the type it defaults — so a `Types::Date.default { Date.today }`
530
+ is still a `Date` for subtyping, JSON Schema and [Codecs](#encoders-and-codecs) — and
531
+ what the block returns is checked against it, failing where it is defaulted rather
532
+ than somewhere downstream.
533
+
534
+ What is checked is the type's **output**, and the type itself is not re-run on the
535
+ generated value: a converting type expects the block to produce the converted value.
536
+
537
+ ```ruby
538
+ int = Types::String.transform(::Integer, &:to_i)
539
+ int.default { 10 }.parse() # 10
540
+ int.default { '10' }.parse() # raises — a String is not what this type produces
541
+ ```
542
+
413
543
  Note that this is syntax sugar for:
414
544
 
415
545
  ```ruby
@@ -504,7 +634,6 @@ ten.parse(10) # => 10
504
634
  ten.parse(100) # => 10
505
635
  ten.parse('hello') # => 10
506
636
  ten.parse() # => 10
507
- ten.metadata[:type] # => Integer
508
637
  ```
509
638
 
510
639
  Useful for data structures where some fields shouldn't change. Example:
@@ -517,12 +646,6 @@ CreateUserEvent = Types::Hash[
517
646
  ]
518
647
  ```
519
648
 
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
649
  This usage is similar as using `Types::Static['hello']`directly.
527
650
 
528
651
  This helper is shorthand for the following composition:
@@ -531,11 +654,11 @@ This helper is shorthand for the following composition:
531
654
  Types::Static[value] >> step
532
655
  ```
533
656
 
534
- This means that validations and coercions in the original step are still applied to the static value.
657
+ 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
658
 
536
659
  ```ruby
537
- ten = Types::Integer[100..].static(10)
538
- ten.parse # => Plumb::ParseError "Must be within 100..."
660
+ Types::Integer[100..].static(10) # raises Plumb::TypeError (10 is not within 100..)
661
+ type = Types::Integer[100..].static(150) # ok
539
662
  ```
540
663
 
541
664
  So, normally you'd only use this attached to primitive types without further processing (but your use case may vary).
@@ -573,21 +696,109 @@ type.metadata[:description] # 'A long text'
573
696
  `#metadata` combines keys from type compositions.
574
697
 
575
698
  ```ruby
576
- type = Types::String.metadata(description: 'A long text') >> Types::String.match(/@/).metadata(note: 'An email address')
699
+ type = Types::String[/@/].metadata(note: 'An email address') >> Types::String.metadata(description: 'A long text')
577
700
  type.metadata[:description] # 'A long text'
578
701
  type.metadata[:note] # 'An email address'
579
702
  ```
580
703
 
581
- `#metadata` also computes the target type.
704
+ `#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).
705
+
706
+ TODO: document custom visitors.
707
+
708
+ #### `#input_type` and `#output_type`
709
+
710
+ Every type exposes the type it expects as input and the type it produces as output.
582
711
 
583
712
  ```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]
713
+ StringToInt = Types::String.transform(Integer, &:to_i)
714
+ StringToInt.input_type # Types::String
715
+ StringToInt.output_type # Integer
588
716
  ```
589
717
 
590
- TODO: document custom visitors.
718
+ 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`:
719
+
720
+ ```ruby
721
+ chain = Types::String.transform(Integer, &:to_i) >> Types::Integer.transform(Integer) { |i| i * 2 }
722
+ chain.input_type # Types::String — the chain can only be called with a String
723
+ chain.output_type # Integer — it can only produce an Integer
724
+ chain.children # [(Types::String -> Integer), (Types::Integer -> Integer)]
725
+ ```
726
+
727
+ For a plain type, both are the type itself. Unions distribute over both sides:
728
+
729
+ ```ruby
730
+ (Types::String | Types::Integer).input_type # Types::String | Types::Integer
731
+ (Types::String | Types::Integer).output_type # Types::String | Types::Integer
732
+ ```
733
+
734
+ 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.
735
+
736
+ #### Composition type-checks
737
+
738
+ `#>>` 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.
739
+
740
+ ```ruby
741
+ Types::String >> Types::Integer # raises: String is not a subtype of Integer
742
+ Types::Numeric >> Types::Integer # raises: Numeric is broader than Integer
743
+ Types::Integer[0..40] >> Types::Integer[2..10] # raises: the left can emit values (0,1,11..40) the right rejects
744
+
745
+ Types::Integer >> Types::Numeric # ok: every Integer is a Numeric
746
+ Types::Integer[2..10] >> Types::Integer[0..40] # ok: 2..10 is within 0..40
747
+ ```
748
+
749
+ 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:
750
+
751
+ ```ruby
752
+ Types::Integer[0..40][2..10] # narrow to 2..10 (runtime-checked)
753
+ Types::String.transform(Integer, &:to_i)[1..10] # convert, then bound the result
754
+ ```
755
+
756
+ 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"):
757
+
758
+ ```ruby
759
+ Types::Integer / Types::Integer[2..10] # narrow without the build-time check
760
+ Types::String / Types::String[/@/] # a String you assert is an email downstream
761
+ ```
762
+
763
+ 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.)
764
+
765
+ 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:
766
+
767
+ ```ruby
768
+ # the consumer requires :age, but the producer never provides it:
769
+ Types::Hash[name: Types::String] >> Types::Hash[name: Types::String, age: Types::Integer]
770
+ # => Plumb::TypeError
771
+
772
+ # a shared key whose value type isn't a subtype:
773
+ Types::Hash[name: Types::String] >> Types::Hash[name: Types::Integer]
774
+ # => Plumb::TypeError
775
+
776
+ # ok — producer is a subtype of consumer (wider, with subtype values):
777
+ Types::Hash[name: Types::Integer, age: Types::Integer] >> Types::Hash[name: Types::Numeric]
778
+ ```
779
+
780
+ [`#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:
781
+
782
+ ```ruby
783
+ Types::Array.where(size: 10) >> Types::Array.where(size: 8..100) # ok: 10 is within 8..100
784
+ Types::Array.where(size: 10..15) >> Types::Array.where(size: 11..14) # raises: 10..15 isn't within 11..14
785
+ ```
786
+
787
+ #### Subtype checks: `#<=` and `Plumb::Subtyping`
788
+
789
+ 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):
790
+
791
+ ```ruby
792
+ Types::Integer <= Types::Numeric # true
793
+ Types::Numeric <= Types::Integer # false
794
+ Types::String[/@/] <= Types::String # true (more refined => a subset)
795
+ Types::Integer <= Numeric # true (compares against a raw Ruby class)
796
+ Types::Array[Integer] <= Types::Array[Numeric] # true (covariant in the element type)
797
+
798
+ big = Types::Hash[name: Types::String, age: Types::Integer]
799
+ small = Types::Hash[name: Types::String]
800
+ big <= small # true (width + depth subtyping)
801
+ ```
591
802
 
592
803
  ### Other policies
593
804
 
@@ -630,7 +841,7 @@ Wraps a step's execution, rescues a specific exception and returns an invalid re
630
841
 
631
842
  Useful for turning a 3rd party library's exception into an invalid result that plays well with Plumb's type compositions.
632
843
 
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.
844
+ Example: parsing strings with `Date.parse` and turning `Date::Error` exceptions into Plumb errors.
634
845
 
635
846
  ```ruby
636
847
  # Accept a string that can be parsed into a Date
@@ -645,6 +856,9 @@ type.resolve('2024-02-02') # => Result::Valid with Date object
645
856
  type.resolve('2024-') # => Result::Invalid with error message
646
857
  ```
647
858
 
859
+ The guard keeps the type it wraps: the example above is still a `Date` for subtyping,
860
+ JSON Schema and [Codecs](#encoders-and-codecs).
861
+
648
862
  ### `Types::Interface`
649
863
 
650
864
  Use this for objects that must respond to one or more methods.
@@ -810,10 +1024,27 @@ StaffMember = User + Employee # Hash[:name, :age, :company]
810
1024
 
811
1025
  #### Hash intersections
812
1026
 
813
- Use `Types::Hash#&` to produce a new Hash definition with keys present in both.
1027
+ 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
1028
 
815
1029
  ```ruby
816
- intersection = User & Employee # Hash[:name]
1030
+ User & Employee # => Hash[name: String] (only the shared :name survives)
1031
+
1032
+ # shared keys have their value types intersected
1033
+ Types::Hash[age: Types::Integer[18..]] & Types::Hash[age: Types::Integer[..65]]
1034
+ # => Hash[age: Integer[18..65]]
1035
+ ```
1036
+
1037
+ 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:
1038
+
1039
+ ```ruby
1040
+ Types::Hash[a: Types::Integer] & Types::Hash[b: Types::String] # => Types::Never
1041
+ ```
1042
+
1043
+ A [`_` catch-all](#undeclared-keys-and-the-_-catch-all) widens what survives, since it admits the other side's extra keys:
1044
+
1045
+ ```ruby
1046
+ Types::Hash[a: Types::String, _: Types::Any] & Types::Hash[a: Types::String, b: Types::Integer]
1047
+ # => Hash[a: String, b: Integer] (:b admitted via the left's catch-all)
817
1048
  ```
818
1049
 
819
1050
  #### `Types::Hash#tagged_by`
@@ -833,18 +1064,41 @@ Events = Types::Hash.tagged_by(
833
1064
  Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition
834
1065
  ```
835
1066
 
836
- #### `Types::Hash#inclusive`
1067
+ #### Undeclared keys and the `_` catch-all
1068
+
1069
+ By default, keys present in the input but **not** declared in the schema are dropped:
1070
+
1071
+ ```ruby
1072
+ Types::Hash[age: Types::Integer].parse(age: 30, name: 'Joe') # => { age: 30 } (:name dropped)
1073
+ ```
1074
+
1075
+ 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:
837
1076
 
838
- Use `#inclusive` to preserve input keys not defined in the hash schema.
1077
+ | Catch-all | Meaning | Undeclared key `name`… |
1078
+ | --- | --- | --- |
1079
+ | _(none)_ | drop (default) | is removed |
1080
+ | `_: Types::Any` | include, unchanged | is kept as-is |
1081
+ | `_: SomeType` | include, validated/coerced | must be a `SomeType` (coerced if the type coerces) |
1082
+ | `_: Types::Never` | exclude (strict) | is a validation **error** |
839
1083
 
840
1084
  ```ruby
841
- hash = Types::Hash[age: Types::Lax::Integer].inclusive
1085
+ # _: Any — keep every undeclared key, unchanged
1086
+ hash = Types::Hash[age: Types::Lax::Integer, _: Types::Any]
1087
+ hash.parse(age: '30', name: 'Joe', last_name: 'Bloggs')
1088
+ # => { age: 30, name: 'Joe', last_name: 'Bloggs' }
842
1089
 
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' }
1090
+ # _: SomeType every undeclared value must be (or coerce to) that type
1091
+ Types::Hash[id: Types::String, _: Types::Integer].parse(id: 'x', a: 1, b: 2)
1092
+ # => { id: 'x', a: 1, b: 2 }
1093
+ Types::Hash[id: Types::String, _: Types::Integer].resolve(id: 'x', a: 'nope').valid? # => false
1094
+
1095
+ # _: Never — reject any undeclared key (a closed/strict hash)
1096
+ strict = Types::Hash[a: Types::String, _: Types::Never]
1097
+ strict.resolve(a: 'x').valid? # => true
1098
+ strict.resolve(a: 'x', b: 1).valid? # => false (b is not allowed)
845
1099
  ```
846
1100
 
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.
1101
+ `_: 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
1102
 
849
1103
  ```ruby
850
1104
  # Front-end definition does structural validation
@@ -854,15 +1108,29 @@ Front = Types::Hash[price: Integer, name: String, category: String]
854
1108
  IntToMoney = Types::Integer.build(Money)
855
1109
 
856
1110
  # Backend definition turns :price into a Money object, leaves other keys as-is
857
- Back = Types::Hash[price: IntToMoney].inclusive
1111
+ Back = Types::Hash[price: IntToMoney, _: Types::Any]
858
1112
 
859
1113
  # Compose the pipeline
860
1114
  InputHandler = Front >> Back
861
1115
 
862
1116
  InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
863
- # => { price: #<Money fractional:100000 currency:GBP>, name: 'iPhone 15', category: 'smartphone' }
1117
+ # => { price: #<Money fractional:100000 currency:GBP>, name: 'iPhone 15', category: 'smartphones' }
864
1118
  ```
865
1119
 
1120
+ The catch-all also shows up in generated JSON Schema as `additionalProperties`: `_: Any` → `{}` (anything), `_: Integer` → `{ "type": "integer" }`, and `_: Never` → `{ "not": {} }` (nothing allowed).
1121
+
1122
+ #### Typed keys
1123
+
1124
+ 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:
1125
+
1126
+ ```ruby
1127
+ Types::Hash['name' => Types::String] # a String key
1128
+ Types::Hash[Types::String[/^id_/] => Types::Integer, # keys matching /^id_/ hold Integers
1129
+ _: Types::Any] # everything else passes through
1130
+ ```
1131
+
1132
+ 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".
1133
+
866
1134
  #### `Types::Hash#filtered`
867
1135
 
868
1136
  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 +1141,97 @@ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
873
1141
  User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
874
1142
  ```
875
1143
 
1144
+ ### `Types::Range`
1145
+
1146
+ `Types::Range` validates that a value is a Ruby `Range`. On its own it accepts any range:
1147
+
1148
+ ```ruby
1149
+ Types::Range.resolve(1..10) # valid
1150
+ Types::Range.resolve('a'..'z') # valid
1151
+ Types::Range.resolve(5) # invalid ("must be a Range")
1152
+ ```
1153
+
1154
+ 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):
1155
+
1156
+ ```ruby
1157
+ IntRange = Types::Range[Integer]
1158
+ IntRange.resolve(1..10) # valid
1159
+ IntRange.resolve('a'..'z') # invalid (endpoints aren't Integers)
1160
+ IntRange.resolve(1..) # valid (only the present bound is checked)
1161
+ ```
1162
+
1163
+ The member type is any `#===` interface, so a `Range` itself works as the member matcher to bound where the endpoints may fall:
1164
+
1165
+ ```ruby
1166
+ # A range whose endpoints both lie within 1..100
1167
+ Percent = Types::Range[1..100]
1168
+ Percent.resolve(10..20) # valid
1169
+ Percent.resolve(10..200) # invalid (200 is outside 1..100)
1170
+ ```
1171
+
1172
+ #### Open-ended ranges with `#where`
1173
+
1174
+ 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:
1175
+
1176
+ ```ruby
1177
+ # Endless ranges only, eg. (1..)
1178
+ Endless = Types::Range[Integer].where(end: nil)
1179
+ Endless.resolve(1..) # valid
1180
+ Endless.resolve(1..10) # invalid ("must have attribute end === nil")
1181
+
1182
+ # Beginless ranges only, eg. (..10)
1183
+ Beginless = Types::Range[Integer].where(begin: nil)
1184
+ Beginless.resolve(..10) # valid
1185
+ Beginless.resolve(1..10) # invalid
1186
+ ```
1187
+
1188
+ `#where` values are also full `#===` matchers, so an endpoint can be constrained by a type or another range:
1189
+
1190
+ ```ruby
1191
+ # A range that starts at zero or above
1192
+ NonNegativeStart = Types::Range.where(begin: Types::Integer[0..])
1193
+ NonNegativeStart.resolve(5..10) # valid
1194
+ NonNegativeStart.resolve(-5..10) # invalid
1195
+ ```
1196
+
1197
+ #### Composition
1198
+
1199
+ `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:
1200
+
1201
+ ```ruby
1202
+ Types::Range[1..10] <= Types::Range[Integer] # true (covariant)
1203
+
1204
+ # The narrower branch is absorbed
1205
+ Types::Range[Integer] | Types::Range[1..10] # => Range[Integer]
1206
+ ```
1207
+
1208
+ #### JSON Schema
1209
+
1210
+ A `Types::Range` whose member pins numeric bounds maps to JSON Schema's native keywords, preserving an exclusive end as `exclusiveMaximum`:
1211
+
1212
+ ```ruby
1213
+ Plumb::JSONSchemaVisitor.call(Types::Range[0...100], root: false)
1214
+ # => { "type" => "integer", "minimum" => 0, "exclusiveMaximum" => 100 }
1215
+ ```
1216
+
876
1217
  ### `Types::SymbolizedHash`
877
1218
 
878
1219
  This type turns a hash's keys into symbols by calling `#to_sym` on them, and returning a new Hash.
879
1220
 
1221
+ `SymbolizedHash` is a `Symbol => Any` map. You can use it as a _transform_.
1222
+
880
1223
  ```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}
1224
+ UserHash = Types::Hash[name: String]
1225
+ Types::SymbolizedHash.transform(UserHash).parse('name' => 'Joe') # { name: 'Joe' }
884
1226
  ```
885
1227
 
1228
+ You can also use the shortcut `#symbolized`
886
1229
 
1230
+ ```ruby
1231
+ # Symbolize keys, then coerce into a typed Hash.
1232
+ type = Types::Hash[name: String, age: Integer].symbolized
1233
+ type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
1234
+ ```
887
1235
 
888
1236
  ### maps
889
1237
 
@@ -947,6 +1295,47 @@ emails = Types::Array[Types::String[/@/]]
947
1295
 
948
1296
  Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching against the regular expression.
949
1297
 
1298
+ #### Chained array maps fuse into a single pass
1299
+
1300
+ `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:
1301
+
1302
+ ```ruby
1303
+ Trim = Types::String.transform(::String, &:strip)
1304
+ Downcase = Types::String.transform(::String, &:downcase)
1305
+ Symbolize = Types::String.transform(::Symbol, &:to_sym)
1306
+
1307
+ # Written as three separate maps over the collection...
1308
+ Tags = Types::Array[Trim] >> Types::Array[Downcase] >> Types::Array[Symbolize]
1309
+
1310
+ # ...built as one.
1311
+ Tags.class # => Plumb::ArrayClass
1312
+ Tags.inspect # => "Array[(Types::String -> Symbol)]"
1313
+ Tags == Types::Array[Trim >> Downcase >> Symbolize] # => true
1314
+
1315
+ Tags.parse([' RUBY ', ' Plumb', 'CSV ']) # => [:ruby, :plumb, :csv]
1316
+ ```
1317
+
1318
+ 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.
1319
+
1320
+ Validation is unaffected. The JSON Schema still describes the input side, and errors are still keyed by element index:
1321
+
1322
+ ```ruby
1323
+ Tags.to_json_schema # => {"type" => "array", "items" => {"type" => "string"}}
1324
+ Tags.resolve(['ok', 42, ' fine ']).errors # => {1 => "Must be a String"}
1325
+ ```
1326
+
1327
+ `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:
1328
+
1329
+ ```ruby
1330
+ # Narrowing isn't provable, so this stays two passes (and `#>>` would reject it outright).
1331
+ Types::Array[Trim] / Types::Array[Types::String[/^a/]]
1332
+
1333
+ # Different containers, so no functor law to apply.
1334
+ Types::Array[Trim] / Types::Stream[Symbolize]
1335
+ ```
1336
+
1337
+ 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.
1338
+
950
1339
  #### Concurrent arrays
951
1340
 
952
1341
  Use `Types::Array#concurrent` to process array elements concurrently (using Concurrent Ruby for now).
@@ -1323,7 +1712,7 @@ class DBConfig < Types::Data
1323
1712
  end
1324
1713
 
1325
1714
  class Config < Types::Data
1326
- attribute :host, Types::Forms::URI::HTTP, writer: true
1715
+ attribute :host, Plumb::Codec::HTTPURIEncoder, writer: true
1327
1716
  attribute :port, Types::Integer.default(80), writer: true
1328
1717
 
1329
1718
  # Nested structs can have writers too
@@ -1388,6 +1777,30 @@ result = CreateUser.resolve(name: 'Joe', age: 40)
1388
1777
  # result.value => User
1389
1778
  ```
1390
1779
 
1780
+ ##### `#step` (non-strict) and `#step!` (strict)
1781
+
1782
+ 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.
1783
+
1784
+ ```ruby
1785
+ pl.step Types::Hash # non-strict: a later step may narrow this
1786
+ pl.step! Types::Hash[name: Types::String] # strict: raises if it can't accept the prior output
1787
+ ```
1788
+
1789
+ 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:
1790
+
1791
+ ```ruby
1792
+ User = Data.define(:name)
1793
+
1794
+ pipeline = Types::Any.pipeline do |pl|
1795
+ pl.step Types::Hash[name: Types::String]
1796
+ # output type + a block that produces it (the block takes and returns a Result)
1797
+ pl.step(User) { |result| result.valid(User.new(result.value[:name])) }
1798
+ end
1799
+
1800
+ pipeline.output_type == Plumb::Composable.wrap(User) # true
1801
+ pipeline.parse(name: 'Joe') # => #<data User name="Joe">
1802
+ ```
1803
+
1391
1804
  Pipelines are Plumb steps, so they can be composed further.
1392
1805
 
1393
1806
  ```ruby
@@ -1513,10 +1926,6 @@ Pipe2 = DebuggablePipeline.new do |pl|
1513
1926
  end
1514
1927
  ```
1515
1928
 
1516
- ### Plumb::Schema
1517
-
1518
- TODO
1519
-
1520
1929
  ### Recursive types
1521
1930
 
1522
1931
  You can use a proc to defer evaluation of recursive definitions.
@@ -1550,6 +1959,197 @@ LinkedList = Types::Hash[
1550
1959
 
1551
1960
 
1552
1961
 
1962
+ ### Encoders and Codecs
1963
+
1964
+ 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.
1965
+
1966
+ #### Defining encoders
1967
+
1968
+ An encoder is a class declaring an input and an output type, with `#decode` (input ⇒ output) and `#encode` (output ⇒ input) methods:
1969
+
1970
+ ```ruby
1971
+ DateRange = Types::Range[Types::Date]
1972
+ JSONDateRange = Types::Hash[from: Types::Date, to: Types::Date]
1973
+
1974
+ class JSONDateRangeEncoder < Plumb::Encoder[JSONDateRange => DateRange]
1975
+ def encode(range) = { from: range.begin, to: range.end }
1976
+ def decode(hash) = hash[:from]..hash[:to]
1977
+ end
1978
+ ```
1979
+
1980
+ 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.
1981
+
1982
+ ```ruby
1983
+ # Decode: the declared direction.
1984
+ FromJSON = JSONDateRange >> JSONDateRangeEncoder >> DateRange
1985
+ FromJSON.parse({ from: Date.new(2024, 1, 1), to: Date.new(2024, 2, 1) }) # => Date..Date range
1986
+
1987
+ # Encode: inferred from the DateRange on the left.
1988
+ ToJSON = DateRange >> JSONDateRangeEncoder >> JSONDateRange
1989
+ ToJSON.parse(Date.new(2024, 1, 1)..Date.new(2024, 2, 1)) # => { from: ..., to: ... }
1990
+ ```
1991
+
1992
+ 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`.
1993
+
1994
+ 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:
1995
+
1996
+ ```ruby
1997
+ JSONDateRangeEncoder.decoding # JSONDateRange -> DateRange, runs #decode
1998
+ JSONDateRangeEncoder.encoding # DateRange -> JSONDateRange, runs #encode
1999
+
2000
+ JSONDateRangeEncoder.decode(from: Date.new(2024, 1, 1), to: Date.new(2024, 2, 1)) # => a Range
2001
+ JSONDateRangeEncoder.encode(Date.new(2024, 1, 1)..Date.new(2024, 2, 1)) # => a Hash
2002
+ ```
2003
+
2004
+ Encoders also express lenient unions — `Types::Date | SomeDateEncoder` accepts a `Date` or decodes a string into one.
2005
+
2006
+ #### Codecs
2007
+
2008
+ 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`:
2009
+
2010
+ ```ruby
2011
+ # Plumb::Codec::JSON ships noops for String, Numeric, booleans, Nil and bare
2012
+ # Hash/Array, plus built-in string encoders for Dates, Times, URIs,
2013
+ # Symbols and Decimals.
2014
+ class JSONCodec < Plumb::Codec::JSON
2015
+ encoder JSONDateRangeEncoder
2016
+ end
2017
+ ```
2018
+
2019
+ (A subclass encoder registered for an equivalent type — eg. your own Date encoder — takes precedence over an inherited built-in.)
2020
+
2021
+ Composing a codec with a type rewrites the type deeply, in either direction:
2022
+
2023
+ ```ruby
2024
+ Person = Types::Hash[name: Types::String, dates: DateRange]
2025
+
2026
+ JSONPerson = JSONCodec >> Person # decode: JSON structures -> Person
2027
+ JSONPerson.parse({ name: 'Joe', dates: { from: '2024-01-01', to: '2024-02-01' } })
2028
+ # => { name: 'Joe', dates: Date(2024-01-01)..Date(2024-02-01) }
2029
+
2030
+ EncodedPerson = Person >> JSONCodec # encode: Person -> JSON structures
2031
+ EncodedPerson.parse({ name: 'Joe', dates: Date.new(2024, 1, 1)..Date.new(2024, 2, 1) })
2032
+ # => { name: 'Joe', dates: { from: '2024-01-01', to: '2024-02-01' } }
2033
+ ```
2034
+
2035
+ `Codec.for(type)` returns both directions as a `[decoding, encoding]` pair:
2036
+
2037
+ ```ruby
2038
+ decoder, encoder = JSONCodec.for(Person)
2039
+ decoder.parse(json_data) # => a Person hash
2040
+ encoder.parse(person_hash) # => JSON structures
2041
+ ```
2042
+
2043
+ 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.
2044
+
2045
+ Codecs work with any type, not just schemas:
2046
+
2047
+ ```ruby
2048
+ (JSONCodec >> Types::Date).parse('2024-01-01') # => Date
2049
+ (Types::Date >> JSONCodec).parse(Date.new(2024, 1, 1)) # => '2024-01-01'
2050
+ JSONCodec >> Types::String # => Types::String, unchanged (noop)
2051
+ ```
2052
+
2053
+ 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:
2054
+
2055
+ ```ruby
2056
+ class Company < Types::Data
2057
+ attribute :name, Types::String
2058
+ attribute :founded, Types::Date
2059
+ end
2060
+
2061
+ decoder, encoder = JSONCodec.for(Company)
2062
+ company = decoder.parse({ name: 'ACME', founded: '2024-01-01' }) # => #<Company founded: Date>
2063
+ encoder.parse(company) # => { name: 'ACME', founded: '2024-01-01' }
2064
+ ```
2065
+
2066
+ 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:
2067
+
2068
+ ```ruby
2069
+ class ParseRecord
2070
+ extend Plumb::Implementation[Types::Hash[on: Types::Date] => Record]
2071
+
2072
+ def self._call(result) = result.valid(Record.new(result.value[:on]))
2073
+ end
2074
+
2075
+ (JSONCodec >> ParseRecord).parse({ on: '2024-01-01' }) # => #<Record on: Date>
2076
+ ```
2077
+
2078
+ 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.
2079
+
2080
+ A field that matches no encoder and no noop is a composition-time error naming the field path:
2081
+
2082
+ ```ruby
2083
+ JSONCodec >> Types::Hash[profile: Types::Hash[joined: Types::Any[Time]]]
2084
+ # raises Plumb::TypeError: ... field `profile.joined` (Any[Time]) matches no encoder ...
2085
+ ```
2086
+
2087
+ 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:
2088
+
2089
+ ```ruby
2090
+ JSONPerson.to_json_schema
2091
+ # "dates" is described as { "type" => "object", "properties" => { "from" => { "type" => "string" }, ... } }
2092
+ ```
2093
+
2094
+ #### Codec instances: a registry of pre-built pairs
2095
+
2096
+ Composing a codec rewrites the whole type tree, so it belongs at boot — not on the path of every message. A codec _instance_ is a registry of `[decoder, encoder]` pairs, each built once by `register` and then looked up by key:
2097
+
2098
+ ```ruby
2099
+ CODECS = JSONCodec.new do |c|
2100
+ c.register('person.created', Person)
2101
+ c.register('company.created', Company)
2102
+ c.register(Types::Date) # the key defaults to the type itself
2103
+ end
2104
+
2105
+ CODECS.decode('person.created', payload) # => a Person hash
2106
+ CODECS.encode('person.created', person) # => JSON structures
2107
+ CODECS.decode(Types::Date, '2024-01-01') # => Date
2108
+ ```
2109
+
2110
+ Keys are yours to choose — a message name, a content type, the type itself. `decode` and `encode` only `#parse`, so the rewrite is paid for once.
2111
+
2112
+ An instance built with a block is frozen when the block returns. Without one it stays open, and `register` chains:
2113
+
2114
+ ```ruby
2115
+ registry = JSONCodec.new
2116
+ registry.register('day', Types::Date).register('person', Person)
2117
+ registry.freeze
2118
+ ```
2119
+
2120
+ `key?` asks what is registered; an unknown key raises `Plumb::Codec::NoEntryError` (a `KeyError`). Payloads are still validated by their type — a bad one raises `Plumb::ParseError` as usual.
2121
+
2122
+ #### `Codec::Forms`: string-based formats
2123
+
2124
+ 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`).
2125
+
2126
+ ```ruby
2127
+ Config = Types::Hash[
2128
+ host: Types::URI::HTTP,
2129
+ port: Types::Integer,
2130
+ active: Types::Boolean,
2131
+ starts_on: Types::Date | Types::Nil
2132
+ ]
2133
+
2134
+ decoder, encoder = Plumb::Codec::Forms.for(Config)
2135
+ decoder.parse({ host: 'http://example.com', port: '80', active: '1', starts_on: '' })
2136
+ # => { host: URI(...), port: 80, active: true, starts_on: nil }
2137
+ encoder.parse({ host: URI.parse('http://example.com'), port: 80, active: true, starts_on: nil })
2138
+ # => { host: 'http://example.com', port: '80', active: 'true', starts_on: '' }
2139
+ ```
2140
+
2141
+ `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.
2142
+
2143
+ 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`.
2144
+
2145
+ Things to know:
2146
+
2147
+ * Direction inference needs a typed neighbour. Opaque contexts fall back to the declared direction — use `.decoding`/`.encoding` to be explicit.
2148
+ * 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.
2149
+ * `.defer`red fields rewrite lazily, so an unmatched type inside one surfaces at first resolution rather than at composition.
2150
+ * 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.
2151
+ * 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`.
2152
+
1553
2153
  ### Custom types
1554
2154
 
1555
2155
  Every Plumb type exposes the following one-method interface:
@@ -1564,25 +2164,62 @@ The `Result::Valid` class has helper methods `#valid(value) => Result::Valid` an
1564
2164
 
1565
2165
  #### Compose procs or lambdas directly
1566
2166
 
1567
- Piping any `#call` object onto Plumb types will wrap your object in a `Plumb::Step` with all methods necessary for further composition.
2167
+ Piping any `#call` object onto Plumb types wraps your object in a composable step, with all methods necessary for further composition.
1568
2168
 
1569
2169
  ```ruby
1570
2170
  Greeting = Types::String >> ->(result) { result.valid("Hello #{result.value}") }
1571
2171
  ```
1572
2172
 
1573
- #### Wrap a `#call` object in `Plumb::Step` explicitely
2173
+ #### `Plumb::Function[input => output]`
1574
2174
 
1575
- You can also wrap a proc in `Plumb::Step` explicitly.
2175
+ 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.
1576
2176
 
1577
2177
  ```ruby
1578
- Greeting = Plumb::Step.new do |result|
2178
+ Greeting = Plumb::Function[String => String] do |result|
1579
2179
  result.valid("Hello #{result.value}")
1580
2180
  end
2181
+
2182
+ Greeting.parse('Joe') # => 'Hello Joe'
2183
+ Greeting.parse(10) # raises Plumb::ParseError ("Must be a String")
1581
2184
  ```
1582
2185
 
1583
- Note that this example is not prefixed by `Types::String`, so it doesn't first validate that the input is indeed a string.
2186
+ 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:
1584
2187
 
1585
- However, this means that `Greeting` is a `Plumb::Step` which comes with all the Plumb methods and policies.
2188
+ ```ruby
2189
+ Greeting = Plumb::Function[MyGreeter.new, String => String]
2190
+ ```
2191
+
2192
+ Because both ends are declared, the resulting step takes part in [composition type checks](#composition-type-checks) and JSON Schema generation, just like `#transform`:
2193
+
2194
+ ```ruby
2195
+ StringLength = Plumb::Function[String => Integer] { |result| result.valid(result.value.size) }
2196
+ StringLength.input_type # => String
2197
+ StringLength.output_type # => Integer
2198
+
2199
+ Types::Integer >> StringLength # raises Plumb::TypeError at build time
2200
+ ```
2201
+
2202
+ You can also pass a custom `#call(Result) => Result` interface as the first argument, to turn a callable into a typed function.
2203
+
2204
+ ```ruby
2205
+ TypedGreeting = Plumb::Function[Greeting.new('Mr.'), String => String]
2206
+ TypedGreeting.parse('Joe') # "Mr. Joe"
2207
+ TypedGreeting.parse(10) # raises Plumb::ParseError
2208
+ ```
2209
+
2210
+
2211
+
2212
+ Omit the types when the callable is genuinely untyped. Both ends default to `Types::Any`, and the step opts out of composition checks.
2213
+
2214
+ ```ruby
2215
+ Greeting = Plumb::Function[] do |result|
2216
+ result.valid("Hello #{result.value}")
2217
+ end
2218
+ ```
2219
+
2220
+ Note that this last example doesn't validate that the input is indeed a String, whereas `Plumb::Function[String => String]` does.
2221
+
2222
+ Either way, `Greeting` is a full Plumb step, which comes with all the Plumb methods and policies.
1586
2223
 
1587
2224
  ```ruby
1588
2225
  # Greeting responds to #>>, #|, #default, #transform, etc etc
@@ -1599,11 +2236,11 @@ class Greeting
1599
2236
  @gr = gr
1600
2237
  end
1601
2238
 
1602
- # The Plumb Step interface
2239
+ # The Plumb step interface
1603
2240
  # @param result [Plumb::Result::Valid]
1604
2241
  # @return [Plumb::Result::Valid, Plumb::Result::Invalid]
1605
2242
  def call(result)
1606
- result.valid("#{gr} #{result.value}")
2243
+ result.valid("#{@gr} #{result.value}")
1607
2244
  end
1608
2245
  end
1609
2246
 
@@ -1614,7 +2251,7 @@ This is useful when you want to parameterize your custom steps, for example by i
1614
2251
 
1615
2252
  #### Include `Plumb::Composable` to make instance of a class full "steps"
1616
2253
 
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.
2254
+ 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
2255
 
1619
2256
  Including `Plumb::Composable` makes it support all Plumb methods directly.
1620
2257
 
@@ -1628,9 +2265,9 @@ class Greeting
1628
2265
  @gr = gr
1629
2266
  end
1630
2267
 
1631
- # The Step interface
2268
+ # The step interface
1632
2269
  def call(result)
1633
- result.valid("#{gr} #{result.value}")
2270
+ result.valid("#{@gr} #{result.value}")
1634
2271
  end
1635
2272
 
1636
2273
  # This is optional, but it allows you to control your object's #inspect
@@ -1650,15 +2287,149 @@ LoudGreeting = Greeting.new('Hola').default('no greeting').invoke(:upcase)
1650
2287
  class User
1651
2288
  extend Composable
1652
2289
 
1653
- def self.class(result)
2290
+ def self.call(result)
1654
2291
  # do something here. Perhaps returning a Result with an instance of this class
1655
- return result.valid(new)
2292
+ result.valid(new)
1656
2293
  end
1657
2294
  end
1658
2295
  ```
1659
2296
 
1660
2297
  This is how [Plumb::Types::Data](#typesdata) is implemented.
1661
2298
 
2299
+ #### Include `Plumb::Implementation[input => output]` to declare a class' types
2300
+
2301
+ `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.
2302
+
2303
+ `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`.
2304
+
2305
+ The mixin owns the public `#call`, which runs the declared checks around your `#_call`:
2306
+
2307
+ ```
2308
+ result.map(input_type).map(_call).map(output_type)
2309
+ ```
2310
+
2311
+ 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.
2312
+
2313
+ ```ruby
2314
+ class UserFinder
2315
+ include Plumb::Implementation[Types::UUID::V4 => User]
2316
+
2317
+ def initialize(user_scope)
2318
+ @user_scope = user_scope
2319
+ end
2320
+
2321
+ private def _call(result)
2322
+ user = User.where(level: @user_scope).find_by(id: result.value)
2323
+ return result.invalid(errors: 'no user!') unless user
2324
+
2325
+ result.valid(user)
2326
+ end
2327
+ end
2328
+ ```
2329
+
2330
+ Instances are now fully typed steps:
2331
+
2332
+ ```ruby
2333
+ finder = UserFinder.new('admin')
2334
+
2335
+ finder.parse(some_uuid) # => a User. Raises Plumb::ParseError unless the input is a UUID
2336
+ finder >> some_other_step # composition, type-checked at build time
2337
+ Types::UUID::V4 >> finder # ...on both sides
2338
+ Types::Integer >> finder # => Plumb::TypeError: Integer is not a subtype of UUID::V4
2339
+
2340
+ finder <= User # => true. Like a Function, it is identified by what it PRODUCES
2341
+ finder.to_json_schema # describes the INPUT side, like any other conversion
2342
+
2343
+ Types::Hash[user: finder] # use it anywhere a type is expected
2344
+ ```
2345
+
2346
+ Both sides are wrapped with `Plumb::Composable.wrap`, so raw Ruby classes and hash literals work too: `Plumb::Implementation[{id: Types::String} => User]`.
2347
+
2348
+ 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.
2349
+
2350
+ `include Plumb::Implementation` with no pair declares `Any => Any` — the opaque case, equivalent to `Plumb::Function.opaque`.
2351
+
2352
+ 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.
2353
+
2354
+ ```ruby
2355
+ class AdminFinder < UserFinder
2356
+ # input already validated as a UUID; the User you return is still checked
2357
+ private def _call(result) = result.valid(super.value.becomes(Admin))
2358
+ end
2359
+ ```
2360
+
2361
+ #### Extend `Plumb::Implementation[input => output]` to make the class itself a typed step
2362
+
2363
+ 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`.
2364
+
2365
+ ```ruby
2366
+ class ParseUUID
2367
+ extend Plumb::Implementation[Types::String => Types::UUID::V4]
2368
+
2369
+ def self._call(result) = result.valid(result.value.downcase)
2370
+ end
2371
+
2372
+ ParseUUID.parse('E1D3...') # the class IS the step
2373
+ ParseUUID >> UserFinder.new('admin') # composes like any other type
2374
+ Types::Hash[id: ParseUUID]
2375
+ ParseUUID.to_json_schema
2376
+ ```
2377
+
2378
+ 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:
2379
+
2380
+ ```ruby
2381
+ Plumb::Subtyping.subtype?(ParseUUID, Types::String) # => true
2382
+ ```
2383
+
2384
+ #### Participating in subtype & composition checks
2385
+
2386
+ 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.
2387
+
2388
+ The default leans on two methods your type already has:
2389
+
2390
+ - `#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.
2391
+ - `#==` — structural equality (provided by `Plumb::Composable`).
2392
+
2393
+ ##### The hook
2394
+
2395
+ | Hook | Returns | Used by | Default |
2396
+ | --- | --- | --- | --- |
2397
+ | `#subtype_of?(other)` | `Boolean` | `#<=`, `Plumb::Subtyping.subtype?`, and so `#>>` | reflexive · atomic · same-class covariant `#children` |
2398
+
2399
+ `#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.
2400
+
2401
+ ```ruby
2402
+ # An "even integer" refinement that knows it is a subtype of Integer (and
2403
+ # therefore Numeric), and defers everything else to the default.
2404
+ class EvenInteger
2405
+ include Plumb::Composable
2406
+
2407
+ def call(result)
2408
+ result.value.is_a?(::Integer) && result.value.even? ? result : result.invalid(errors: 'must be even')
2409
+ end
2410
+
2411
+ def subtype_of?(other)
2412
+ Plumb::Subtyping.subtype?(Types::Integer, other) || super
2413
+ end
2414
+
2415
+ private def _inspect = 'EvenInteger'
2416
+ end
2417
+
2418
+ even = EvenInteger.new
2419
+ even <= Types::Integer # => true
2420
+ even <= Types::Numeric # => true
2421
+ even <= Types::String # => false
2422
+ ```
2423
+
2424
+ ##### Type flow: `#input_type` / `#output_type`
2425
+
2426
+ 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:
2427
+
2428
+ - a value-converting step declares a different `#output_type` (what `#transform`/`#build` do via `Plumb::Function`);
2429
+ - an opaque step (a wrapped proc, a generator) returns `Types::Any` for both, opting out of the `#>>` compatibility check.
2430
+
2431
+ Custom types are **values/leaves** in the algebra — you compose them with the built-in combinators (`#>>`, `#|`, `#transform`, `Types::Any`) rather than re-implementing those.
2432
+
1662
2433
  ### Custom policies
1663
2434
 
1664
2435
  `Plumb.policy` can be used to encapsulate common type compositions, or compositions that can be configurable by parameters.
@@ -1709,7 +2480,7 @@ end
1709
2480
 
1710
2481
  # Usage: annotate fields in a schema
1711
2482
  AccountName = Types::String.admin
1712
- AccountName.metadata # => { type: String, admin: true }
2483
+ AccountName.metadata # => { admin: true }
1713
2484
  ```
1714
2485
 
1715
2486
  #### Type-specific policies
@@ -1767,6 +2538,13 @@ Plumb.policy :split, SplitPolicy
1767
2538
 
1768
2539
  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
2540
 
2541
+ 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:
2542
+
2543
+ ```ruby
2544
+ # Accepts a String, coerces it to an Integer
2545
+ Types::String.transform(Integer, &:to_i).to_json_schema # => { "type" => "string" }
2546
+ ```
2547
+
1770
2548
  ```ruby
1771
2549
  Payload = Types::Hash[name: String]
1772
2550
  Payload.to_json_schema(root: true)
@@ -1826,12 +2604,57 @@ Types::DateTime.to_json_schema
1826
2604
  # {"type"=>"string", "format"=>"date-time"}
1827
2605
  ```
1828
2606
 
2607
+ ##### Node names for compositions
2608
+
2609
+ 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):
2610
+
2611
+ | Node name | Built by | Meaning |
2612
+ | --------------- | --------------------------------- | ----------------------------------------------------------- |
2613
+ | `:and` | `#>>` with a converting side | Sequential composition — consumes the left's input, produces the right's output |
2614
+ | `:intersection` | `#>>`, `#/`, `#where`, `#check`, `#&` | The meet — both sides constrain the *same* value |
2615
+ | `:or` | `#\|` with a converting branch | Left-biased choice — a branch may coerce, so the ends differ |
2616
+ | `:union` | `#\|` with value-preserving branches | The join — a plain set of alternatives |
2617
+
2618
+ 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.
2619
+
2620
+ ### Mermaid diagrams
2621
+
2622
+ 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).
2623
+
2624
+ ```ruby
2625
+ type = (A >> B) | (C >> (D | B))
2626
+ puts type.to_mermaid
2627
+ ```
2628
+
2629
+ ```mermaid
2630
+ flowchart LR
2631
+ start(( ))
2632
+ n1["A"]
2633
+ n2["B"]
2634
+ n3["C"]
2635
+ n4["D"]
2636
+ n5["B"]
2637
+ start --> n1
2638
+ start --> n3
2639
+ n1 --> n2
2640
+ n3 --> n4
2641
+ n3 --> n5
2642
+ ```
2643
+
2644
+ 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.
2645
+
2646
+ The direction is configurable, and the visitor can be used directly:
2647
+
2648
+ ```ruby
2649
+ type.to_mermaid(direction: 'TB')
2650
+ Plumb::MermaidVisitor.call(type)
2651
+ ```
2652
+
1829
2653
 
1830
2654
 
1831
2655
  ## TODO:
1832
2656
 
1833
2657
  - [ ] benchmarks and performace. Compare with `Parametric`, `ActiveModel::Attributes`, `ActionController::StrongParameters`
1834
- - [ ] flesh out `Plumb::Schema`
1835
2658
  - [x] `Plumb::Struct`
1836
2659
  - [x] flesh out and document `Plumb::Pipeline`
1837
2660
  - [ ] document custom visitors
@@ -1845,7 +2668,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
1845
2668
 
1846
2669
  ## Contributing
1847
2670
 
1848
- Bug reports and pull requests are welcome on GitHub at https://github.com/ismasan/plumb.
2671
+ Bug reports and pull requests are welcome on GitHub at [github.com/ismasan/plumb](https://github.com/ismasan/plumb).
1849
2672
 
1850
2673
  ## License
1851
2674