Skip to content

Commit 7585166

Browse files
committed
feat: add fromResult()/fromValidatedResult() — never-throw error accumulation
Safe-parse alternative to from()/tryFrom(): tries every parameter and collects every failure into a HydrationResult instead of aborting on the first one, with dot-path keys for nested BaseData/#[DataCollection] errors. Compiled and cached separately from the hydration hot path, so from()/tryFrom() pay nothing for it.
1 parent 2b867a5 commit 7585166

21 files changed

Lines changed: 1291 additions & 34 deletions

‎CHANGELOG.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,33 @@ All notable changes to `std-out/simple-data-objects` are documented here.
55
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.23.0] — 2026-08-08
9+
10+
### Added
11+
- **`fromResult()` / `fromValidatedResult()`** — a "safe parse" alternative
12+
to `from()`/`tryFrom()` that never throws. Every parameter is tried, and
13+
every failure is collected into a `HydrationResult` instead of aborting
14+
on the first one.
15+
- **`HydrationResult`** — `ok()`, `value()` (throws `LogicException` if
16+
called on a failed result), `valueOrNull()`, `errors()`.
17+
- **Dot-path errors for nested structure**: a nested `BaseData` or
18+
`#[DataCollection]` field recurses into the target class's own
19+
`fromResult()`, merging its errors under `field.name` /
20+
`field.index.name`. `#[Flatten]` merges flat, with no prefix, since its
21+
fields already live in the parent's own namespace.
22+
- `#[RejectUnknownKeys]` reports a `'$unknown'` entry instead of
23+
aborting; a failing class-level `#[Pipe]` reports `'$pipeline'` (and
24+
skips per-field extraction, since the transform left the input
25+
unreliable); a throwing constructor reports `'$construct'`; invalid
26+
non-array input reports `'$input'`.
27+
- **`fromValidatedResult()`** merges `#[Rules]`/`#[InferRules]` failures
28+
into the same error map (the first message per field; a validation
29+
message wins over a hydration message on the same key).
30+
- Compiled the same way `from()` is — a specialized closure per class,
31+
cached separately and lazily — so `from()`/`tryFrom()` pay nothing for
32+
it, whether or not a class ever calls `fromResult()`.
33+
- **Documentation:** see [fromResult() — Error Accumulation](https://std-out.github.io/simple-data-objects/features/error-accumulation).
34+
835
## [1.22.0] — 2026-08-05
936

1037
### Added

‎README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,20 @@ public function store(CreateOrderData $data) { /* already validated */ }
181181
CreateOrderData::validate($rawArray); // throws ValidationException
182182
```
183183

184+
### Never-throw error accumulation
185+
186+
`fromResult()` tries every field instead of stopping at the first one, with dot-paths for nested DTOs and collections:
187+
188+
```php
189+
$result = CreateOrderData::fromResult($request->all());
190+
191+
$result->ok(); // bool
192+
$result->errors(); // ['deliveryDate' => 'Invalid date format', 'items.2.price' => '...']
193+
$result->value(); // CreateOrderData — throws if !ok()
194+
195+
// fromValidatedResult() merges in #[Rules] failures the same way
196+
```
197+
184198
---
185199

186200
## All Attributes

‎docs/.vitepress/config.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export default defineConfig({
7676
{ text: 'Hydration', link: '/features/hydration' },
7777
{ text: 'Serialization', link: '/features/serialization' },
7878
{ text: 'Validation', link: '/features/validation' },
79+
{ text: 'fromResult() — Error Accumulation', link: '/features/error-accumulation' },
7980
{ text: 'DataPipe — Preprocessing', link: '/features/pipes' },
8081
{ text: 'Immutable Copies — with()', link: '/features/with' },
8182
{ text: 'Comparison — equals() & diff()', link: '/features/comparison' },

‎docs/attributes/reject-unknown-keys.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,7 @@ Both combinations are rejected at metadata-build time, not silently ignored:
7373
## Standalone constructor-less, hybrid, and lazy classes
7474

7575
Works the same on [constructor-less and hybrid DTOs](../features/hydration.md#constructor-less-dtos) — the known-key set includes properties populated via constructor injection and via post-construction assignment alike. With [`fromLazy()`](../features/hydration.md#lazy-hydration), the check runs on first property access, same as any other hydration error.
76+
77+
## Interaction with fromResult()
78+
79+
Under [`fromResult()`](../features/error-accumulation.md), an unknown key doesn't abort — it's reported as a `'$unknown'` entry alongside whatever field errors also accumulated, instead of throwing immediately.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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.

‎docs/features/hydration.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ if ($user === null) {
7979
Use `tryFrom()` when you want to handle bad input gracefully. Use `from()` for trusted internal data.
8080
:::
8181

82+
`tryFrom()` tells you *that* hydration failed, but not *why* — it discards the reason and stops at the first bad field. When you need every problem at once (with a field name attached to each), use [`fromResult()`](./error-accumulation.md) instead.
83+
8284
## Nested DTOs
8385

8486
Type-hint a property as another `BaseData` subclass and it is hydrated automatically:

‎docs/features/validation.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,7 @@ class CreateUserController
126126
}
127127
}
128128
```
129+
130+
## Never throwing: fromValidatedResult()
131+
132+
`fromValidated()`/`validate()` throw on the first problem. For a "collect every error and never throw" alternative — merging `#[Rules]` failures with hydration failures into one map, with dot-paths for nested DTOs and collections — see [`fromResult()` — Error Accumulation](./error-accumulation.md).

‎docs/guide/introduction.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ $order->toJson(); // JSON string
4242
## Core Concepts
4343

4444
### Hydration
45-
`from()` is a universal factory: it accepts arrays, Eloquent models and any `Arrayable`, `stdClass`, `JsonSerializable`, any `Traversable`, JSON strings, and plain objects. Nested DTOs, enums, and collections are resolved automatically.
45+
`from()` is a universal factory: it accepts arrays, Eloquent models and any `Arrayable`, `stdClass`, `JsonSerializable`, any `Traversable`, JSON strings, and plain objects. Nested DTOs, enums, and collections are resolved automatically. `tryFrom()` returns `null` instead of throwing; `fromResult()` goes further and never throws either, collecting every field's error (dot-pathed for nested DTOs and collections) instead of stopping at the first one.
4646

4747
### Serialization
4848
`toArray()` and `toJson()` serialize the object back to its wire format. Casts apply in both directions — hydration reads them, serialization writes them.

‎src/BaseData.php‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,35 @@ public static function tryFrom(mixed $data): ?static
110110
}
111111
}
112112

113+
/**
114+
* Like from(), but never throws — every failure is collected into the
115+
* returned result instead of aborting on the first one.
116+
*
117+
* @return HydrationResult<static>
118+
*/
119+
public static function fromResult(mixed $data): HydrationResult
120+
{
121+
$collect = HydratorCompiler::$collectingHydrators[static::class] ?? HydratorCompiler::compileCollecting(static::class);
122+
123+
if (is_array($data)) {
124+
/** @var HydrationResult<static> */
125+
return $collect($data);
126+
}
127+
128+
if ($data instanceof static) {
129+
return HydrationResult::success($data);
130+
}
131+
132+
try {
133+
$data = InputNormalizer::normalize(static::class, $data);
134+
} catch (DataHydrationException $e) {
135+
return HydrationResult::failure(['$input' => $e->getMessage()]);
136+
}
137+
138+
/** @var HydrationResult<static> */
139+
return $collect($data);
140+
}
141+
113142
/**
114143
* @return TypedDataCollection<static>
115144
*/
@@ -208,6 +237,57 @@ public static function fromValidated(mixed $data): static
208237
return static::from($array);
209238
}
210239

240+
/**
241+
* fromResult(), with Rules validation errors merged into the same map
242+
* (validation errors win on a key collision). Never throws.
243+
*
244+
* @return HydrationResult<static>
245+
*/
246+
public static function fromValidatedResult(mixed $data): HydrationResult
247+
{
248+
if (! is_array($data)) {
249+
try {
250+
$data = InputNormalizer::normalize(static::class, $data);
251+
} catch (DataHydrationException $e) {
252+
return HydrationResult::failure(['$input' => $e->getMessage()]);
253+
}
254+
}
255+
256+
$meta = MetadataRegistry::get(static::class);
257+
258+
// Same delegation as fromValidated(): the concrete class's rules apply
259+
if ($meta->discriminatorField !== null) {
260+
try {
261+
$target = self::resolveDiscriminated($meta, $data);
262+
} catch (DataHydrationException $e) {
263+
return HydrationResult::failure([(string) $meta->discriminatorField => $e->getMessage()]);
264+
}
265+
266+
/** @var HydrationResult<static> */
267+
return $target::fromValidatedResult($data);
268+
}
269+
270+
$result = static::fromResult($data);
271+
272+
if ($meta->validationRules === []) {
273+
return $result;
274+
}
275+
276+
$validator = static::validatorFactory()->make($data, $meta->validationRules);
277+
278+
if (! $validator->fails()) {
279+
return $result;
280+
}
281+
282+
$validationErrors = [];
283+
284+
foreach ($validator->errors()->messages() as $key => $messages) {
285+
$validationErrors[$key] = $messages[0];
286+
}
287+
288+
return HydrationResult::failure([...$result->errors(), ...$validationErrors]);
289+
}
290+
211291
/** @throws ValidationException */
212292
public static function validate(mixed $data): void
213293
{

‎src/HydrationResult.php‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace StdOut\SimpleDataObjects;
6+
7+
/**
8+
* @template T
9+
*/
10+
final class HydrationResult
11+
{
12+
/**
13+
* @param T|null $value
14+
* @param array<string, string> $errors
15+
*/
16+
private function __construct(
17+
private readonly bool $ok,
18+
private readonly mixed $value,
19+
private readonly array $errors,
20+
) {}
21+
22+
/**
23+
* @template U
24+
*
25+
* @param U $value
26+
* @return self<U>
27+
*/
28+
public static function success(mixed $value): self
29+
{
30+
return new self(true, $value, []);
31+
}
32+
33+
/**
34+
* @param array<string, string> $errors
35+
* @return self<never>
36+
*/
37+
public static function failure(array $errors): self
38+
{
39+
return new self(false, null, $errors);
40+
}
41+
42+
public function ok(): bool
43+
{
44+
return $this->ok;
45+
}
46+
47+
/** @return T */
48+
public function value(): mixed
49+
{
50+
return $this->ok
51+
? $this->value
52+
: throw new \LogicException('Cannot read value() of a failed HydrationResult — check ok() or use errors() first.');
53+
}
54+
55+
/** @return T|null */
56+
public function valueOrNull(): mixed
57+
{
58+
return $this->ok ? $this->value : null;
59+
}
60+
61+
/** @return array<string, string> */
62+
public function errors(): array
63+
{
64+
return $this->errors;
65+
}
66+
}

0 commit comments

Comments
 (0)