$npx -y skills add Lonsdale201/wp-agent-skills --skill bd-hydration-coercionModify how raw values become typed property values in
| 1 | # better-data: Hydration and coercion |
| 2 | |
| 3 | For library maintainers fixing or extending how stored / incoming values become typed property values on a `DataObject`. The coercion layer sits between the source's raw fetch and the constructor's typed parameters; modifying it touches every DTO that goes through `::fromArray`. |
| 4 | |
| 5 | ## Misconception this skill corrects |
| 6 | |
| 7 | > "I'll just `settype($value, 'int')` or `(int) $value` inside the hydrator — same effect." |
| 8 | |
| 9 | Wrong. PHP's silent casts paper over invalid input — `(int) 'abc' === 0`, `(int) '12foo' === 12`, `(bool) 'false' === true`. better-data's coercion is intentionally strict: surprising input becomes `TypeCoercionException` with the field name, expected type, and offending value. Verified at [src/Internal/TypeCoercer.php:46-58](TypeCoercer.php) and the per-helper throws ([toString:197, toInt:218, toFloat:235, toBool:254](TypeCoercer.php)). |
| 10 | |
| 11 | The discipline is: |
| 12 | |
| 13 | ```php |
| 14 | // WRONG inside coercion code |
| 15 | $intValue = (int) $value; |
| 16 | |
| 17 | // RIGHT |
| 18 | $intValue = TypeCoercer::toInt($dtoClass, $fieldName, $value); |
| 19 | // throws TypeCoercionException if $value isn't a coercible int — caller gets the field |
| 20 | // name and value in the message instead of silently storing 0. |
| 21 | ``` |
| 22 | |
| 23 | Other AI-prone misconceptions: |
| 24 | |
| 25 | - "I'll add a WP function call inside `TypeCoercer` — it makes the code shorter." Wrong — `TypeCoercer` is the one engine that MUST stay WP-free so its tests can run without a WP runtime. WP-aware logic goes in `DataObject::coerceParameter` or in the source. |
| 26 | - "I'll add the `Encrypted` decryption to `TypeCoercer`." Wrong layer — attribute-aware coercion lives ABOVE `TypeCoercer` in `DataObject::coerceParameter` ([src/DataObject.php:168](DataObject.php)). The pattern is: handle the attribute (decrypt, walk the list), then call `TypeCoercer::coerce` with the simpler value. |
| 27 | |
| 28 | ## When to use this skill |
| 29 | |
| 30 | Trigger when ANY of the following is true: |
| 31 | |
| 32 | - A bug report says "fromArray hydrates with the wrong type" or "casting issue". |
| 33 | - The diff modifies `src/Internal/TypeCoercer.php` or `src/DataObject.php::coerceParameter`. |
| 34 | - Adding support for a new primitive type or a new attribute that affects coercion. |
| 35 | - Reviewing a PR that calls `settype()`, `intval()`, `(int)`, or `(string)` inside coercion code. |
| 36 | - Hitting `TypeCoercionException` at runtime and triaging. |
| 37 | |
| 38 | ## Workflow |
| 39 | |
| 40 | ### 1. Choose the layer |
| 41 | |
| 42 | | Change type | Layer | |
| 43 | |---|---| |
| 44 | | New primitive (decimal type, IPv4 stored as string <-> int) | `TypeCoercer` (pure) | |
| 45 | | New WP-builtin handling (e.g. coerce `WP_Term` to a term ID) | `TypeCoercer` (still pure — `WP_Term` is just a class shape; check `instanceof` doesn't require WP runtime) | |
| 46 | | New attribute affects coercion (`#[Slug]` lowercase before string-coerce) | `DataObject::coerceParameter` (above TypeCoercer) | |
| 47 | | New attribute affects encryption / list coercion | `DataObject::coerceParameter` | |
| 48 | |
| 49 | The acid test: "Can my code run inside a unit test that does NOT bootstrap WordPress?" If yes, it can live in `TypeCoercer`. If no (calls `wp_remote_get`, reads `$wpdb`, looks up `WP_User`), it must live elsewhere. |
| 50 | |
| 51 | ### 2. Adding a primitive coercion |
| 52 | |
| 53 | Inside `TypeCoercer::coerce` ([src/Internal/TypeCoercer.php:83-88](TypeCoercer.php)): |
| 54 | |
| 55 | ```php |
| 56 | return match ($targetTypeName) { |
| 57 | 'string' => self::toString(...), |
| 58 | 'int' => self::toInt(...), |
| 59 | 'float' => self::toFloat(...), |
| 60 | 'bool' => self::toBool(...), |
| 61 | 'array' => self::toArray(...), |
| 62 | // your new branch: |
| 63 | 'decimal' => self::toDecimal($dataObjectClass, $fieldName, $value), |
| 64 | default => throw TypeCoercionException::unsupportedType(...), |
| 65 | }; |
| 66 | ``` |
| 67 | |
| 68 | The helper: |
| 69 | |
| 70 | ```php |
| 71 | private static function toDecimal(string $cls, string $field, mixed $value): Decimal |
| 72 | { |
| 73 | if ($value instanceof Decimal) { |