plumb 0.2.0.beta.1 → 0.2.0.beta.3

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.
data/README.md CHANGED
@@ -303,7 +303,7 @@ Types::Never.to_json_schema # => { "not" => {} }
303
303
 
304
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
305
 
306
- ### Built-in types
306
+ ## Built-in types
307
307
 
308
308
  * `Types::Value`
309
309
  * `Types::Array`
@@ -338,11 +338,11 @@ For parsing stringy formats (HTML forms, query strings) into these types — wha
338
338
 
339
339
  TODO: datetime, others.
340
340
 
341
- ### Policies
341
+ ## Policies
342
342
 
343
343
  Policies are helpers that encapsulate common compositions. Plumb ships with some handy ones, listed below, and you can also define your own.
344
344
 
345
- #### `#present`
345
+ ### `#present`
346
346
 
347
347
  Checks that the value is not blank (`""` if string, `[]` if array, `{}` if Hash, or `nil`)
348
348
 
@@ -351,7 +351,7 @@ Types::String.present.resolve('') # Failure with errors
351
351
  Types::Array[Types::String].present.resolve([]) # Failure with errors
352
352
  ```
353
353
 
354
- #### `#nullable`
354
+ ### `#nullable`
355
355
 
356
356
  Allow `nil` values.
357
357
 
@@ -368,7 +368,7 @@ Note that this just encapsulates the following composition:
368
368
  nullable_str = Types::String | Types::Nil
369
369
  ```
370
370
 
371
- #### `#not`
371
+ ### `#not`
372
372
 
373
373
  Negates a type.
374
374
  ```ruby
@@ -394,7 +394,7 @@ NotNil.parse('hello') # 'hello'
394
394
  NotNil.parse(nil) # error
395
395
  ```
396
396
 
397
- #### `#options`
397
+ ### `#options`
398
398
 
399
399
  Sets allowed options for value.
400
400
 
@@ -442,7 +442,7 @@ Types::Array.where(size: 10) >> Types::Array.where(size: 8..100) # ok: 10 is wit
442
442
  Types::Array.where(size: 10..15) >> Types::Array.where(size: 11..14) # raises: 10..15 isn't within 11..14
443
443
  ```
444
444
 
445
- #### `#transform`
445
+ ### `#transform`
446
446
 
447
447
  Transform value. Requires specifying the resulting type of the value after transformation.
448
448
 
@@ -472,7 +472,7 @@ Types::Any.transform(:to_i) # ok — unknown input type, no check
472
472
 
473
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
474
 
475
- #### `#invoke`
475
+ ### `#invoke`
476
476
 
477
477
  `#invoke` builds a step that will invoke one or more methods on the value.
478
478
 
@@ -509,7 +509,7 @@ type.parse([1, 2]) # raises NoMethodError because Array doesn't respond to #stri
509
509
 
510
510
  Use with caution.
511
511
 
512
- #### `#default`
512
+ ### `#default`
513
513
 
514
514
  Default value when no value given (ie. when key is missing in Hash payloads. See `Types::Hash` below).
515
515
 
@@ -519,6 +519,27 @@ str.parse() # 'nope'
519
519
  str.parse('yup') # 'yup'
520
520
  ```
521
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
+
522
543
  Note that this is syntax sugar for:
523
544
 
524
545
  ```ruby
@@ -543,7 +564,7 @@ Same if you want to apply a default to several cases.
543
564
  str = Types::String | ((Types::Nil | Types::Undefined) >> Types::Static['nope'.freeze])
544
565
  ```
545
566
 
546
- #### `#build`
567
+ ### `#build`
547
568
 
548
569
  Build a custom object or class.
549
570
 
@@ -576,7 +597,7 @@ Note that this case is identical to `#transform` with a block.
576
597
  StringToMoney = Types::String.transform(Money) { |value| Monetize.parse(value) }
577
598
  ```
578
599
 
579
- #### `#check`
600
+ ### `#check`
580
601
 
581
602
  Pass the value through an arbitrary validation
582
603
 
@@ -586,7 +607,7 @@ type.parse('Role: Manager') # 'Role: Manager'
586
607
  type.parse('Manager') # fails
587
608
  ```
588
609
 
589
- #### `#value`
610
+ ### `#value`
590
611
 
591
612
  Constrain a type to a specific value. Compares with `#==`
592
613
 
@@ -603,7 +624,7 @@ All scalar types support this:
603
624
  ten = Types::Integer.value(10)
604
625
  ```
605
626
 
606
- #### `#static`
627
+ ### `#static`
607
628
 
608
629
  A type that always returns a valid, static value, regardless of input.
609
630
 
@@ -642,7 +663,7 @@ type = Types::Integer[100..].static(150) # ok
642
663
 
643
664
  So, normally you'd only use this attached to primitive types without further processing (but your use case may vary).
644
665
 
645
- #### `#generate`
666
+ ### `#generate`
646
667
 
647
668
  Passing a proc will evaluate the proc on every invocation. Use this for generated values.
648
669
 
@@ -661,7 +682,7 @@ random_number.parse # raises Plumb::ParseError because `rand` is not a String
661
682
 
662
683
  You can also pass any `#call() => Object` interface as a generator, instead of a proc.
663
684
 
664
- #### `#metadata`
685
+ ### `#metadata`
665
686
 
666
687
  Add metadata to a type
667
688
 
@@ -684,7 +705,7 @@ type.metadata[:note] # 'An email address'
684
705
 
685
706
  TODO: document custom visitors.
686
707
 
687
- #### `#input_type` and `#output_type`
708
+ ### `#input_type` and `#output_type`
688
709
 
689
710
  Every type exposes the type it expects as input and the type it produces as output.
690
711
 
@@ -712,7 +733,7 @@ For a plain type, both are the type itself. Unions distribute over both sides:
712
733
 
713
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.
714
735
 
715
- #### Composition type-checks
736
+ ### Composition type-checks
716
737
 
717
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.
718
739
 
@@ -763,7 +784,7 @@ Types::Array.where(size: 10) >> Types::Array.where(size: 8..100) # ok: 10 is
763
784
  Types::Array.where(size: 10..15) >> Types::Array.where(size: 11..14) # raises: 10..15 isn't within 11..14
764
785
  ```
765
786
 
766
- #### Subtype checks: `#<=` and `Plumb::Subtyping`
787
+ ### Subtype checks: `#<=` and `Plumb::Subtyping`
767
788
 
768
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):
769
790
 
@@ -779,11 +800,11 @@ small = Types::Hash[name: Types::String]
779
800
  big <= small # true (width + depth subtyping)
780
801
  ```
781
802
 
782
- ### Other policies
803
+ ## Other policies
783
804
 
784
805
  There's some other built-in "policies" that can be used via the `#policy` method. Helpers such as `#default` and `#present` are shortcuts for this and can also be used via `#policy(default: 'Hello')` or `#policy(:present)` See [custom policies](#custom-policies) for how to define your own policies.
785
806
 
786
- #### `:respond_to`
807
+ ### `:respond_to`
787
808
 
788
809
  Similar to `Types::Interface`, this is a quick way to assert that a value supports one or more methods.
789
810
 
@@ -793,7 +814,7 @@ List = Types::Any.policy(respond_to: :each)
793
814
  List = Types::Any.policy(respond_to: [:each, :[], :size)
794
815
  ```
795
816
 
796
- #### `:excluded_from`
817
+ ### `:excluded_from`
797
818
 
798
819
  The opposite of `#options`, this policy validates that the value _is not_ included in a list.
799
820
 
@@ -801,7 +822,7 @@ The opposite of `#options`, this policy validates that the value _is not_ includ
801
822
  Name = Types::String.policy(excluded_from: ['Joe', 'Joan'])
802
823
  ```
803
824
 
804
- #### :split` (strings only)
825
+ ### :split` (strings only)
805
826
 
806
827
  Splits string values by a separator (default: `,`).
807
828
 
@@ -814,7 +835,7 @@ CSVLine = Types::String.split(/\s*;\s*/)
814
835
  CSVLine.parse('a;b;c') # => ['a', 'b', 'c']
815
836
  ```
816
837
 
817
- #### `:rescue`
838
+ ### `:rescue`
818
839
 
819
840
  Wraps a step's execution, rescues a specific exception and returns an invalid result.
820
841
 
@@ -835,7 +856,10 @@ type.resolve('2024-02-02') # => Result::Valid with Date object
835
856
  type.resolve('2024-') # => Result::Invalid with error message
836
857
  ```
837
858
 
838
- ### `Types::Interface`
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
+
862
+ ## `Types::Interface`
839
863
 
840
864
  Use this for objects that must respond to one or more methods.
841
865
 
@@ -870,7 +894,7 @@ case args
870
894
  end
871
895
  ```
872
896
 
873
- #### Merging interfaces
897
+ ### Merging interfaces
874
898
 
875
899
  Use the `+` operator to merge two interfaces into a new one that must support both sets of method names.
876
900
 
@@ -881,7 +905,7 @@ Countable = Types::Interface[:size]
881
905
  CountableIterable = Iterable + Countable
882
906
  ```
883
907
 
884
- #### Intersecting interfaces
908
+ ### Intersecting interfaces
885
909
 
886
910
  Use the `&` operator to produce a new interface with the intersection of method names
887
911
 
@@ -895,7 +919,7 @@ I3 = Types::Interface[:b, :c]
895
919
 
896
920
  TODO: make this a bit more advanced. Check for method arity.
897
921
 
898
- ### `Types::Hash`
922
+ ## `Types::Hash`
899
923
 
900
924
  ```ruby
901
925
  Employee = Types::Hash[
@@ -964,7 +988,7 @@ User = Types::Hash[name: Types::Static['Joe'], age: Integer]
964
988
  User.parse(name: 'Rufus', age: 34) # Valid {name: 'Joe', age: 34}
965
989
  ```
966
990
 
967
- #### Optional keys
991
+ ### Optional keys
968
992
 
969
993
  Keys suffixed with `?` are marked as optional and its values will only be validated and coerced if the key is present in the input hash.
970
994
 
@@ -988,7 +1012,7 @@ Types::Hash[
988
1012
  ]
989
1013
  ```
990
1014
 
991
- #### Merging hash definitions
1015
+ ### Merging hash definitions
992
1016
 
993
1017
  Use `Types::Hash#+` to merge two definitions. Keys in the second hash override the first one's.
994
1018
 
@@ -998,7 +1022,7 @@ Employee = Types::Hash[name: Types::String, company: Types::String]
998
1022
  StaffMember = User + Employee # Hash[:name, :age, :company]
999
1023
  ```
1000
1024
 
1001
- #### Hash intersections
1025
+ ### Hash intersections
1002
1026
 
1003
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:
1004
1028
 
@@ -1023,7 +1047,7 @@ Types::Hash[a: Types::String, _: Types::Any] & Types::Hash[a: Types::String, b:
1023
1047
  # => Hash[a: String, b: Integer] (:b admitted via the left's catch-all)
1024
1048
  ```
1025
1049
 
1026
- #### `Types::Hash#tagged_by`
1050
+ ### `Types::Hash#tagged_by`
1027
1051
 
1028
1052
  Use `#tagged_by` to resolve what definition to use based on the value of a common key.
1029
1053
 
@@ -1040,7 +1064,7 @@ Events = Types::Hash.tagged_by(
1040
1064
  Events.parse(type: 'name_updated', name: 'Joe') # Uses NameUpdatedEvent definition
1041
1065
  ```
1042
1066
 
1043
- #### Undeclared keys and the `_` catch-all
1067
+ ### Undeclared keys and the `_` catch-all
1044
1068
 
1045
1069
  By default, keys present in the input but **not** declared in the schema are dropped:
1046
1070
 
@@ -1095,7 +1119,7 @@ InputHandler.parse(price: 100_000, name: 'iPhone 15', category: 'smartphones')
1095
1119
 
1096
1120
  The catch-all also shows up in generated JSON Schema as `additionalProperties`: `_: Any` → `{}` (anything), `_: Integer` → `{ "type": "integer" }`, and `_: Never` → `{ "not": {} }` (nothing allowed).
1097
1121
 
1098
- #### Typed keys
1122
+ ### Typed keys
1099
1123
 
1100
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:
1101
1125
 
@@ -1107,7 +1131,7 @@ Types::Hash[Types::String[/^id_/] => Types::Integer, # keys matching /^id_/ hold
1107
1131
 
1108
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".
1109
1133
 
1110
- #### `Types::Hash#filtered`
1134
+ ### `Types::Hash#filtered`
1111
1135
 
1112
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.
1113
1137
 
@@ -1117,7 +1141,7 @@ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
1117
1141
  User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
1118
1142
  ```
1119
1143
 
1120
- ### `Types::Range`
1144
+ ## `Types::Range`
1121
1145
 
1122
1146
  `Types::Range` validates that a value is a Ruby `Range`. On its own it accepts any range:
1123
1147
 
@@ -1145,7 +1169,7 @@ Percent.resolve(10..20) # valid
1145
1169
  Percent.resolve(10..200) # invalid (200 is outside 1..100)
1146
1170
  ```
1147
1171
 
1148
- #### Open-ended ranges with `#where`
1172
+ ### Open-ended ranges with `#where`
1149
1173
 
1150
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:
1151
1175
 
@@ -1170,7 +1194,7 @@ NonNegativeStart.resolve(5..10) # valid
1170
1194
  NonNegativeStart.resolve(-5..10) # invalid
1171
1195
  ```
1172
1196
 
1173
- #### Composition
1197
+ ### Composition
1174
1198
 
1175
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:
1176
1200
 
@@ -1181,7 +1205,7 @@ Types::Range[1..10] <= Types::Range[Integer] # true (covariant)
1181
1205
  Types::Range[Integer] | Types::Range[1..10] # => Range[Integer]
1182
1206
  ```
1183
1207
 
1184
- #### JSON Schema
1208
+ ### JSON Schema
1185
1209
 
1186
1210
  A `Types::Range` whose member pins numeric bounds maps to JSON Schema's native keywords, preserving an exclusive end as `exclusiveMaximum`:
1187
1211
 
@@ -1190,7 +1214,7 @@ Plumb::JSONSchemaVisitor.call(Types::Range[0...100], root: false)
1190
1214
  # => { "type" => "integer", "minimum" => 0, "exclusiveMaximum" => 100 }
1191
1215
  ```
1192
1216
 
1193
- ### `Types::SymbolizedHash`
1217
+ ## `Types::SymbolizedHash`
1194
1218
 
1195
1219
  This type turns a hash's keys into symbols by calling `#to_sym` on them, and returning a new Hash.
1196
1220
 
@@ -1209,7 +1233,7 @@ type = Types::Hash[name: String, age: Integer].symbolized
1209
1233
  type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
1210
1234
  ```
1211
1235
 
1212
- ### maps
1236
+ ## maps
1213
1237
 
1214
1238
  You can also use Hash syntax to define a hash map with specific types for all keys and values:
1215
1239
 
@@ -1240,7 +1264,7 @@ Use `Types::Value` to validate specific values (using `#==`)
1240
1264
  names_and_ones = Types::Hash[String, Types::Integer.value(1)]
1241
1265
  ```
1242
1266
 
1243
- #### `#filtered`
1267
+ ### `#filtered`
1244
1268
 
1245
1269
  Calling the `#filtered` modifier on a Hash Map makes it return a sub set of the keys and values that are valid as per the key and value type definitions.
1246
1270
 
@@ -1253,7 +1277,7 @@ S3Config.parse(ENV.to_h) # { 'S3_BUCKET' => 'foo', 'S3_REGION' => 'us-east-1' }
1253
1277
 
1254
1278
 
1255
1279
 
1256
- ### `Types::Array`
1280
+ ## `Types::Array`
1257
1281
 
1258
1282
  ```ruby
1259
1283
  names = Types::Array[Types::String.present]
@@ -1271,7 +1295,7 @@ emails = Types::Array[Types::String[/@/]]
1271
1295
 
1272
1296
  Prefer the latter (`Types::Array[Types::String[/@/]]`), as that first validates that each element is a `String` before matching against the regular expression.
1273
1297
 
1274
- #### Chained array maps fuse into a single pass
1298
+ ### Chained array maps fuse into a single pass
1275
1299
 
1276
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:
1277
1301
 
@@ -1312,7 +1336,7 @@ Types::Array[Trim] / Types::Stream[Symbolize]
1312
1336
 
1313
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.
1314
1338
 
1315
- #### Concurrent arrays
1339
+ ### Concurrent arrays
1316
1340
 
1317
1341
  Use `Types::Array#concurrent` to process array elements concurrently (using Concurrent Ruby for now).
1318
1342
 
@@ -1335,13 +1359,13 @@ See the [concurrent downloads example](https://github.com/ismasan/plumb/blob/mai
1335
1359
 
1336
1360
  TODO: pluggable concurrency engines (Async?)
1337
1361
 
1338
- #### `#stream`
1362
+ ### `#stream`
1339
1363
 
1340
1364
  Turn an Array definition into an enumerator that yields each element wrapped in `Result::Valid` or `Result::Invalid`.
1341
1365
 
1342
1366
  See [`Types::Stream`](#typesstream) below for more.
1343
1367
 
1344
- #### `#filtered`
1368
+ ### `#filtered`
1345
1369
 
1346
1370
  The `#filtered` modifier makes an array definition return a subset of the input array where the values are valid, as per the array's element type.
1347
1371
 
@@ -1352,7 +1376,7 @@ j_names.parse(%w[james ismael joe toby joan isabel]) # ["james", "joe", "joan"]
1352
1376
 
1353
1377
 
1354
1378
 
1355
- ### `Types::Tuple`
1379
+ ## `Types::Tuple`
1356
1380
 
1357
1381
  ```ruby
1358
1382
  Status = Types::Symbol.options(%i[ok error])
@@ -1384,7 +1408,7 @@ NameAndRegex = Types::Tuple[String, Types::Value[/@/]]
1384
1408
 
1385
1409
 
1386
1410
 
1387
- ### `Types::Stream`
1411
+ ## `Types::Stream`
1388
1412
 
1389
1413
  `Types::Stream` defines an enumerator that validates/coerces each element as it iterates.
1390
1414
 
@@ -1410,7 +1434,7 @@ end
1410
1434
 
1411
1435
  See a more complete the [CSV Stream example](https://github.com/ismasan/plumb/blob/main/examples/csv_stream.rb)
1412
1436
 
1413
- #### `Types::Stream#filtered`
1437
+ ### `Types::Stream#filtered`
1414
1438
 
1415
1439
  Use `#filtered` to turn a `Types::Stream` into a stream that only yields valid elements.
1416
1440
 
@@ -1421,7 +1445,7 @@ ValidElements.parse(data).each do |valid_row|
1421
1445
  end
1422
1446
  ```
1423
1447
 
1424
- #### `Types::Array#stream`
1448
+ ### `Types::Array#stream`
1425
1449
 
1426
1450
  A `Types::Array` definition can be turned into a stream.
1427
1451
 
@@ -1436,11 +1460,11 @@ Str.parse(data).each do |row|
1436
1460
  end
1437
1461
  ```
1438
1462
 
1439
- ### Types::Data
1463
+ ## Types::Data
1440
1464
 
1441
1465
  `Types::Data` provides a superclass to define **immutable** structs or value objects with typed / coercible attributes.
1442
1466
 
1443
- #### `[]` Syntax
1467
+ ### `[]` Syntax
1444
1468
 
1445
1469
  The `[]` syntax is a short-hand for struct definition.
1446
1470
  Like `Plumb::Types::Hash`, suffixing a key with `?` makes it optional.
@@ -1473,7 +1497,7 @@ PersonHash = Types::Hash[name: String, age?: Integer]
1473
1497
  PersonStruct = Types::Data[PersonHash]
1474
1498
  ```
1475
1499
 
1476
- #### `#with`
1500
+ ### `#with`
1477
1501
 
1478
1502
  Note that these instances cannot be mutated (there's no attribute setters), but they can be copied with partial attributes with `#with`
1479
1503
 
@@ -1481,7 +1505,7 @@ Note that these instances cannot be mutated (there's no attribute setters), but
1481
1505
  another_person = person.with(age: 20)
1482
1506
  ```
1483
1507
 
1484
- #### `.attribute` syntax
1508
+ ### `.attribute` syntax
1485
1509
 
1486
1510
  This syntax allows defining struct classes with typed attributes, including nested structs.
1487
1511
 
@@ -1564,7 +1588,7 @@ Note that this does NOT work with union'd or piped structs.
1564
1588
  attribute :company, Company | Person do
1565
1589
  ```
1566
1590
 
1567
- #### Shorthand array syntax
1591
+ ### Shorthand array syntax
1568
1592
 
1569
1593
  ```ruby
1570
1594
  attribute :things, [] # Same as attribute :things, Types::Array
@@ -1581,7 +1605,7 @@ Note that, if you want to match an attribute value against a literal array, you
1581
1605
  attribute :one_two_three, Types::Array.value[[1, 2, 3]])
1582
1606
  ```
1583
1607
 
1584
- #### Optional Attributes
1608
+ ### Optional Attributes
1585
1609
 
1586
1610
  Using `attribute?` allows for optional attributes. If the attribute is not present, these attribute values will be `nil`
1587
1611
 
@@ -1589,7 +1613,7 @@ Using `attribute?` allows for optional attributes. If the attribute is not prese
1589
1613
  attribute? :company, Company
1590
1614
  ```
1591
1615
 
1592
- #### Before steps, symbolizing keys
1616
+ ### Before steps, symbolizing keys
1593
1617
 
1594
1618
  The optional `.step` helper adds arbitrary Plumb steps to a Data constructor's internal pipeline.
1595
1619
 
@@ -1632,7 +1656,7 @@ person.last_name # => 'BLOGGS'
1632
1656
 
1633
1657
  A Data class steps are inherited to its child classes.
1634
1658
 
1635
- #### Inheritance
1659
+ ### Inheritance
1636
1660
 
1637
1661
  Data structs can inherit from other structs. This is useful for defining a base struct with common attributes.
1638
1662
 
@@ -1646,7 +1670,7 @@ class Person < BasePerson
1646
1670
  end
1647
1671
  ```
1648
1672
 
1649
- #### Equality with `#==`
1673
+ ### Equality with `#==`
1650
1674
 
1651
1675
  `#==` is implemented to compare attributes, recursively.
1652
1676
 
@@ -1656,7 +1680,7 @@ person2 = Person.new(name: 'Joe', age: 20)
1656
1680
  person1 == person2 # true
1657
1681
  ```
1658
1682
 
1659
- #### Struct composition
1683
+ ### Struct composition
1660
1684
 
1661
1685
  `Types::Data` supports all the composition operators and helpers.
1662
1686
 
@@ -1678,7 +1702,7 @@ Payload = Types::Hash[
1678
1702
  ]
1679
1703
  ```
1680
1704
 
1681
- #### Attribute writers
1705
+ ### Attribute writers
1682
1706
 
1683
1707
  By default `Types::Data` classes are inmutable, but you can define attribute writers to allow for mutation using the `writer: true` option.
1684
1708
 
@@ -1702,7 +1726,7 @@ config.valid? # true
1702
1726
  config.errors # {}
1703
1727
  ```
1704
1728
 
1705
- #### Recursive struct definitions
1729
+ ### Recursive struct definitions
1706
1730
 
1707
1731
  You can use `#defer`. See [recursive types](#recursive-types).
1708
1732
 
@@ -1717,11 +1741,11 @@ person.friend.name # 'joan'
1717
1741
  person.friend.friend # nil
1718
1742
  ```
1719
1743
 
1720
- ### Plumb::Pipeline
1744
+ ## Plumb::Pipeline
1721
1745
 
1722
1746
  `Plumb::Pipeline` offers a sequential, step-by-step syntax for composing processing steps, as well as a simple middleware API to wrap steps for metrics, logging, debugging, caching and more. See the [command objects](https://github.com/ismasan/plumb/blob/main/examples/command_objects.rb) example for a worked use case.
1723
1747
 
1724
- #### `#pipeline` helper
1748
+ ### `#pipeline` helper
1725
1749
 
1726
1750
  All plumb steps have a `#pipeline` helper.
1727
1751
 
@@ -1753,7 +1777,7 @@ result = CreateUser.resolve(name: 'Joe', age: 40)
1753
1777
  # result.value => User
1754
1778
  ```
1755
1779
 
1756
- ##### `#step` (non-strict) and `#step!` (strict)
1780
+ #### `#step` (non-strict) and `#step!` (strict)
1757
1781
 
1758
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.
1759
1783
 
@@ -1787,7 +1811,7 @@ IsJoe = User.check('must be named joe') { |user|
1787
1811
  CreateIfJoe = IsJoe >> CreateUser
1788
1812
  ```
1789
1813
 
1790
- ##### `#around`
1814
+ #### `#around`
1791
1815
 
1792
1816
  Use `#around` in a pipeline definition to add a middleware step that wraps all other steps registered.
1793
1817
 
@@ -1841,7 +1865,7 @@ pl.around do |step, result|
1841
1865
  end
1842
1866
  ```
1843
1867
 
1844
- #### As stand-alone `Plumb::Pipeline` class
1868
+ ### As stand-alone `Plumb::Pipeline` class
1845
1869
 
1846
1870
  `Plumb::Pipeline` can also be used on its own, sub-classed, and it can take class-level `around` middleware.
1847
1871
 
@@ -1886,7 +1910,7 @@ pipe = DebuggablePipeline.new do |pl|
1886
1910
  end
1887
1911
  ```
1888
1912
 
1889
- #### Pipelines all the way down :turtle:
1913
+ ### Pipelines all the way down :turtle:
1890
1914
 
1891
1915
  Pipelines are full Plumb steps, so they can themselves be used as steps.
1892
1916
 
@@ -1902,7 +1926,7 @@ Pipe2 = DebuggablePipeline.new do |pl|
1902
1926
  end
1903
1927
  ```
1904
1928
 
1905
- ### Recursive types
1929
+ ## Recursive types
1906
1930
 
1907
1931
  You can use a proc to defer evaluation of recursive definitions.
1908
1932
 
@@ -1935,11 +1959,11 @@ LinkedList = Types::Hash[
1935
1959
 
1936
1960
 
1937
1961
 
1938
- ### Encoders and Codecs
1962
+ ## Encoders and Codecs
1939
1963
 
1940
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.
1941
1965
 
1942
- #### Defining encoders
1966
+ ### Defining encoders
1943
1967
 
1944
1968
  An encoder is a class declaring an input and an output type, with `#decode` (input ⇒ output) and `#encode` (output ⇒ input) methods:
1945
1969
 
@@ -1979,7 +2003,7 @@ JSONDateRangeEncoder.encode(Date.new(2024, 1, 1)..Date.new(2024, 2, 1))
1979
2003
 
1980
2004
  Encoders also express lenient unions — `Types::Date | SomeDateEncoder` accepts a `Date` or decodes a string into one.
1981
2005
 
1982
- #### Codecs
2006
+ ### Codecs
1983
2007
 
1984
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`:
1985
2009
 
@@ -2067,7 +2091,50 @@ JSONPerson.to_json_schema
2067
2091
  # "dates" is described as { "type" => "object", "properties" => { "from" => { "type" => "string" }, ... } }
2068
2092
  ```
2069
2093
 
2070
- #### `Codec::Forms`: string-based formats
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
+ An open registry also composes _type_ keys on first use, so you need not enumerate every type you exchange:
2123
+
2124
+ ```ruby
2125
+ CODECS = JSONCodec.new # no block, so it stays open
2126
+
2127
+ CODECS.encode(Person, person) # composes Person now, reuses it after
2128
+ CODECS.decode(Person, payload)
2129
+ CODECS.decode(Types::Date, '2024-01-01')
2130
+ CODECS.decode(::Date, '2024-01-01') # raw Ruby classes work too
2131
+ ```
2132
+
2133
+ Only keys that _are_ types compose themselves — an app-owned tag like `'person.created'` names nothing the codec could build, so it still raises `NoEntryError` until you register it. Freezing is how you declare the set closed: a sealed registry never composes anything new, which is what you want at boot when every type is known.
2134
+
2135
+ Open registries are safe to share between threads — composition happens outside a lock, and racing threads simply compose the same (pure) rewrite twice. Note that type keys match by value, so a type literal built fresh on each call (`CODECS.decode(Types::Hash[on: Types::Date], payload)`) adds an entry per call: pass constants, or seal the registry.
2136
+
2137
+ ### `Codec::Forms`: string-based formats
2071
2138
 
2072
2139
  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
2140
 
@@ -2098,7 +2165,7 @@ Things to know:
2098
2165
  * 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
2166
  * 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
2167
 
2101
- ### Custom types
2168
+ ## Custom types
2102
2169
 
2103
2170
  Every Plumb type exposes the following one-method interface:
2104
2171
 
@@ -2110,7 +2177,7 @@ As long as an object implements this interface, it can be composed into Plumb wo
2110
2177
 
2111
2178
  The `Result::Valid` class has helper methods `#valid(value) => Result::Valid` and `#invalid(errors:) => Result::Invalid` to facilitate returning valid or invalid values from your own steps.
2112
2179
 
2113
- #### Compose procs or lambdas directly
2180
+ ### Compose procs or lambdas directly
2114
2181
 
2115
2182
  Piping any `#call` object onto Plumb types wraps your object in a composable step, with all methods necessary for further composition.
2116
2183
 
@@ -2118,7 +2185,7 @@ Piping any `#call` object onto Plumb types wraps your object in a composable ste
2118
2185
  Greeting = Types::String >> ->(result) { result.valid("Hello #{result.value}") }
2119
2186
  ```
2120
2187
 
2121
- #### `Plumb::Function[input => output]`
2188
+ ### `Plumb::Function[input => output]`
2122
2189
 
2123
2190
  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
2191
 
@@ -2174,7 +2241,7 @@ Either way, `Greeting` is a full Plumb step, which comes with all the Plumb meth
2174
2241
  LoudGreeting = Greeting.default('no greeting').invoke(:upcase)
2175
2242
  ```
2176
2243
 
2177
- #### A custom `#call` class
2244
+ ### A custom `#call` class
2178
2245
 
2179
2246
  Or write a custom class that responds to `#call(Result::Valid) => Result::Valid | Result::Invalid`
2180
2247
 
@@ -2197,7 +2264,7 @@ MyType = Types::String >> Greeting.new('Hola')
2197
2264
 
2198
2265
  This is useful when you want to parameterize your custom steps, for example by initialising them with arguments like the example above.
2199
2266
 
2200
- #### Include `Plumb::Composable` to make instance of a class full "steps"
2267
+ ### Include `Plumb::Composable` to make instance of a class full "steps"
2201
2268
 
2202
2269
  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.
2203
2270
 
@@ -2229,7 +2296,7 @@ Now you can use your class as a composition starting point directly.
2229
2296
  LoudGreeting = Greeting.new('Hola').default('no greeting').invoke(:upcase)
2230
2297
  ```
2231
2298
 
2232
- #### Extend a class with `Plumb::Composable` to make the class itself a composable step.
2299
+ ### Extend a class with `Plumb::Composable` to make the class itself a composable step.
2233
2300
 
2234
2301
  ```ruby
2235
2302
  class User
@@ -2244,7 +2311,7 @@ end
2244
2311
 
2245
2312
  This is how [Plumb::Types::Data](#typesdata) is implemented.
2246
2313
 
2247
- #### Include `Plumb::Implementation[input => output]` to declare a class' types
2314
+ ### Include `Plumb::Implementation[input => output]` to declare a class' types
2248
2315
 
2249
2316
  `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
2317
 
@@ -2306,7 +2373,7 @@ class AdminFinder < UserFinder
2306
2373
  end
2307
2374
  ```
2308
2375
 
2309
- #### Extend `Plumb::Implementation[input => output]` to make the class itself a typed step
2376
+ ### Extend `Plumb::Implementation[input => output]` to make the class itself a typed step
2310
2377
 
2311
2378
  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
2379
 
@@ -2329,7 +2396,7 @@ The two forms are alternatives — pick one per class. The extended form deliber
2329
2396
  Plumb::Subtyping.subtype?(ParseUUID, Types::String) # => true
2330
2397
  ```
2331
2398
 
2332
- #### Participating in subtype & composition checks
2399
+ ### Participating in subtype & composition checks
2333
2400
 
2334
2401
  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
2402
 
@@ -2338,7 +2405,7 @@ The default leans on two methods your type already has:
2338
2405
  - `#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
2406
  - `#==` — structural equality (provided by `Plumb::Composable`).
2340
2407
 
2341
- ##### The hook
2408
+ #### The hook
2342
2409
 
2343
2410
  | Hook | Returns | Used by | Default |
2344
2411
  | --- | --- | --- | --- |
@@ -2369,7 +2436,7 @@ even <= Types::Numeric # => true
2369
2436
  even <= Types::String # => false
2370
2437
  ```
2371
2438
 
2372
- ##### Type flow: `#input_type` / `#output_type`
2439
+ #### Type flow: `#input_type` / `#output_type`
2373
2440
 
2374
2441
  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
2442
 
@@ -2378,7 +2445,7 @@ The `#>>` check (and the [JSON Schema visitor](#json-schema)) ask what a type ac
2378
2445
 
2379
2446
  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
2447
 
2381
- ### Custom policies
2448
+ ## Custom policies
2382
2449
 
2383
2450
  `Plumb.policy` can be used to encapsulate common type compositions, or compositions that can be configurable by parameters.
2384
2451
 
@@ -2404,7 +2471,7 @@ The `#policy` helper supports applying multiply policies.
2404
2471
  Types::String.policy(default_if_nil: 'nothing here', size: (10..20))
2405
2472
  ```
2406
2473
 
2407
- #### Policies as helper methods
2474
+ ### Policies as helper methods
2408
2475
 
2409
2476
  Use the `helper: true` option to register the policy as a method you can call on types directly.
2410
2477
 
@@ -2431,7 +2498,7 @@ AccountName = Types::String.admin
2431
2498
  AccountName.metadata # => { admin: true }
2432
2499
  ```
2433
2500
 
2434
- #### Type-specific policies
2501
+ ### Type-specific policies
2435
2502
 
2436
2503
  You can use the `for_type:` option to define policies that only apply to steps that output certain types. This example is only applicable for types that return `Integer` values.
2437
2504
 
@@ -2447,7 +2514,7 @@ Doubled.parse(2) # 4
2447
2514
  DoubledString = Types::String.multiply_by(2) # raises error
2448
2515
  ```
2449
2516
 
2450
- #### Interface-specific policies
2517
+ ### Interface-specific policies
2451
2518
 
2452
2519
  `for_type`also supports a Symbol for a method name, so that the policy can be applied to any types that support that method.
2453
2520
 
@@ -2463,7 +2530,7 @@ DoubledNumeric = Types::Numeric.multiply_by(2)
2463
2530
  DoubledMoney = Types::Any[Money].multiply_by(2)
2464
2531
  ```
2465
2532
 
2466
- #### Self-contained policy modules
2533
+ ### Self-contained policy modules
2467
2534
 
2468
2535
  You can register a module, class or object with a three-method interface as a policy. This is so that policies can have their own namespace if they need local constants or private methods. For example, this is how the `:split` policy for strings is defined.
2469
2536
 
@@ -2482,7 +2549,7 @@ end
2482
2549
  Plumb.policy :split, SplitPolicy
2483
2550
  ```
2484
2551
 
2485
- ### JSON Schema
2552
+ ## JSON Schema
2486
2553
 
2487
2554
  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.
2488
2555
 
@@ -2552,7 +2619,7 @@ Types::DateTime.to_json_schema
2552
2619
  # {"type"=>"string", "format"=>"date-time"}
2553
2620
  ```
2554
2621
 
2555
- ##### Node names for compositions
2622
+ #### Node names for compositions
2556
2623
 
2557
2624
  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
2625
 
@@ -2565,7 +2632,7 @@ Two-sided compositions report one of four `#node_name`s, depending on whether th
2565
2632
 
2566
2633
  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
2634
 
2568
- ### Mermaid diagrams
2635
+ ## Mermaid diagrams
2569
2636
 
2570
2637
  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
2638
 
@@ -2616,7 +2683,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
2616
2683
 
2617
2684
  ## Contributing
2618
2685
 
2619
- Bug reports and pull requests are welcome on GitHub at https://github.com/ismasan/plumb.
2686
+ Bug reports and pull requests are welcome on GitHub at [github.com/ismasan/plumb](https://github.com/ismasan/plumb).
2620
2687
 
2621
2688
  ## License
2622
2689