|
| 1 | +# fromResult() — Error Accumulation |
| 2 | + |
| 3 | +`from()`/`tryFrom()` are fail-fast: the first invalid field throws (or `tryFrom()` swallows it and returns `null`, losing the reason). `fromResult()` is the "safe parse" alternative — analogous to zod's `safeParse` or pydantic's `ValidationError` — it never throws, tries every field, and returns every problem at once. |
| 4 | + |
| 5 | +```php |
| 6 | +use StdOut\SimpleDataObjects\HydrationResult; |
| 7 | + |
| 8 | +$result = OrderData::fromResult($input); |
| 9 | + |
| 10 | +$result->ok(); // bool |
| 11 | +$result->value(); // OrderData — throws LogicException if !ok() |
| 12 | +$result->valueOrNull(); // OrderData|null |
| 13 | +$result->errors(); // ['deliveryDate' => 'Invalid date format', 'items.2.price' => '...'] |
| 14 | +``` |
| 15 | + |
| 16 | +## Dot-path errors for nested DTOs and collections |
| 17 | + |
| 18 | +A nested `BaseData` property or a `#[DataCollection]` recurses into the target class's own `fromResult()`, so a failure several levels deep is still reported with a precise path: |
| 19 | + |
| 20 | +```php |
| 21 | +class OrderData extends BaseData |
| 22 | +{ |
| 23 | + public function __construct( |
| 24 | + public readonly string $customerName, |
| 25 | + public readonly AddressData $shippingAddress, |
| 26 | + #[DataCollection(ItemData::class)] |
| 27 | + public readonly TypedDataCollection $items, |
| 28 | + ) {} |
| 29 | +} |
| 30 | + |
| 31 | +$result = OrderData::fromResult([ |
| 32 | + 'customerName' => 'Ada', |
| 33 | + 'shippingAddress' => ['street' => '1 Ave'], // missing 'city' |
| 34 | + 'items' => [ |
| 35 | + ['sku' => 'A', 'price' => 10], |
| 36 | + ['sku' => 'B'], // missing 'price' |
| 37 | + ], |
| 38 | +]); |
| 39 | + |
| 40 | +$result->errors(); |
| 41 | +// [ |
| 42 | +// 'shippingAddress.city' => "Missing required field 'city' for AddressData.", |
| 43 | +// 'items.1.price' => "Missing required field 'price' for ItemData.", |
| 44 | +// ] |
| 45 | +``` |
| 46 | + |
| 47 | +[`#[Flatten]`](../attributes/flatten.md) fields are the one exception: since a flattened DTO's keys already live in the parent's own namespace, its errors merge in without a prefix. |
| 48 | + |
| 49 | +## What's never a per-field error |
| 50 | + |
| 51 | +- `#[RejectUnknownKeys]` reports unrecognized keys as a single `'$unknown'` entry, alongside any field errors — it doesn't abort the rest of the accumulation. |
| 52 | +- A failing class-level [`#[Pipe]`](../attributes/pipe.md) reports a single `'$pipeline'` entry. Since the array transform itself failed, per-field extraction is skipped for that call — there's nothing reliable left to read. |
| 53 | +- An exception from the constructor itself (e.g. an invariant check in the constructor body) is reported as `'$construct'`. |
| 54 | +- Invalid non-array input (a malformed JSON string, or a type `InputNormalizer` can't convert) is reported as `'$input'`. |
| 55 | + |
| 56 | +## fromValidatedResult() — merge in Rules validation too |
| 57 | + |
| 58 | +Runs `fromResult()` and, if the class declares `#[Rules]` (or [`#[InferRules]`](../attributes/infer-rules.md)), also validates the raw input and merges in the first message per failing rule. A validation message wins over a hydration message on the same key. |
| 59 | + |
| 60 | +```php |
| 61 | +$result = OrderData::fromValidatedResult($request->all()); |
| 62 | + |
| 63 | +if (! $result->ok()) { |
| 64 | + return response()->json(['errors' => $result->errors()], 422); |
| 65 | +} |
| 66 | + |
| 67 | +$order = $result->value(); |
| 68 | +``` |
| 69 | + |
| 70 | +## Performance |
| 71 | + |
| 72 | +`fromResult()` compiles its own specialized closure per class — the same code-generation strategy `from()` uses, not an interpreted walk over metadata — so it stays fast even though it never throws. It's compiled lazily and cached separately from `from()`'s hydrator: classes that never call `fromResult()` pay nothing for it, and `from()`/`tryFrom()` are completely unaffected either way. |
0 commit comments