Skip to content

Commit d605d28

Browse files
committed
feat(transform): skip null by default with configurable parameter
BREAKING CHANGE: transform() now skips null values by default (was executing on null) - Add $skipNull parameter (default: true) to transform() method - Most type conversions don't need null handling, making this safer and more efficient - Use transform($fn, skipNull: false) when null processing is needed - Update tests to reflect new default behavior and add explicit null handling test - Update documentation (llms.txt, guides) with examples and migration notes - Update CHANGELOG.md with breaking change details Refs: Real-world feedback on null handling efficiency
1 parent f000abf commit d605d28

13 files changed

Lines changed: 112 additions & 69 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,25 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Changed
8+
- **BREAKING**: `transform()` now skips `null` values by default (was executing on null)
9+
- **Rationale**: Most type conversions don't need null handling, making this safer and more efficient by default
10+
- **Migration**: Use `transform($fn, skipNull: false)` if you need to process null values (e.g., `transform(fn($v) => $v ?? 'fallback', skipNull: false)`)
11+
- **Impact**: Existing code that relied on `transform()` executing on null will need to explicitly set `skipNull: false`
12+
- **Real-World Benefit**: Prevents errors when null values reach type conversions like `DateTime::createFromFormat()`, `explode()`, `strlen()`, etc.
13+
714
### Added
815
- `notEmpty()` convenience method for `StringValidator` and `ArrayValidator` to reject empty strings/arrays
916
- `between()` shorthand for string length and numeric range constraints with unified error messages
1017
- `in()` alias for `oneOf()` on primitive validators for clearer allowed-value validation
18+
- `transform()` method now accepts optional `$skipNull` parameter (default: `true`) to configure null handling
1119

1220
### Deprecated
1321
- `oneOf()` in favor of `in()` (alias retained for backward compatibility)
1422

1523
### Documentation
1624
- Clarified fail-fast behavior per field and schema-level error aggregation in guides and examples
25+
- Updated transformation documentation to reflect new null handling behavior
1726

1827
## [0.11.2] - 2026-01-05
1928

docs/getting-started/basic-usage.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ $count = Validator::isString()
305305

306306
- **Use `pipe()`** for same-type operations (string → string, array → array)
307307
- **Use `transform()`** for type changes (string → array, array → int)
308-
- **Null handling** - `pipe()` skips null values to avoid type errors, `transform()` runs on null so you can map it to a new value
308+
- **Null handling** - `pipe()` skips null values to avoid type errors, `transform()` skips null by default. Use `transform($fn, skipNull: false)` to process null values.
309309

310310
```php
311311
// Correct usage
@@ -314,6 +314,16 @@ $result = Validator::isArray()
314314
->transform(fn($v) => implode(',', $v)) // Array → String (type change)
315315
->pipe('trim', 'strtoupper') // String operations (same type)
316316
->validate(['a', 'b', 'a']); // Returns: "A,B"
317+
318+
// Null handling: default behavior (skips null)
319+
$date = Validator::isString()
320+
->transform(fn($v) => DateTime::createFromFormat('Y-m-d', $v))
321+
->validate(null); // Returns: null (transform skipped, safe)
322+
323+
// Null handling: explicit processing when needed
324+
$withFallback = Validator::isString()
325+
->transform(fn($v) => $v ?? 'Not provided', skipNull: false)
326+
->validate(null); // Returns: 'Not provided' (transform executed)
317327
```
318328

319329
## Next Steps

docs/getting-started/core-concepts.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,26 @@ $result = Validator::isString()
141141
- **Changes type context** - Updates internal type tracking
142142
- **No type coercion** - Returns exactly what the transformer produces
143143
- **Enables type switching** - Subsequent `pipe()` operations work with the new type
144-
- **Null handling** - `transform()` runs even when the current value is null so you can map null to a new value or type
144+
- **Null handling** - `transform()` skips null by default (most type conversions don't need null). Use `transform($fn, skipNull: false)` to process null values.
145+
146+
**Null Handling Examples:**
147+
148+
```php
149+
// Default behavior: skips null (most common case)
150+
$result = Validator::isString()
151+
->transform(fn($v) => DateTime::createFromFormat('Y-m-d', $v))
152+
->validate(null); // Returns: null (transform skipped, no error)
153+
154+
// Explicit null processing: when you need to handle null
155+
$result = Validator::isString()
156+
->transform(fn($v) => $v ?? 'N/A', skipNull: false)
157+
->validate(null); // Returns: 'N/A' (transform executed on null)
158+
159+
// Converting null to empty array
160+
$result = Validator::isArray()
161+
->transform(fn($v) => $v === null ? [] : $v, skipNull: false)
162+
->validate(null); // Returns: [] (null converted to empty array)
163+
```
145164

146165
#### `pipe()` - Type-Preserving Transformations
147166

llms.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
- **Validations** (`satisfies`, `min`, `email`): Skip `null` values.
99
- **Transformations**:
1010
- `pipe(callable)`: Skips `null` values (type-preserving, expects specific type).
11-
- `transform(callable)`: Executes on `null` (can handle null and change types).
11+
- `transform(callable, bool $skipNull = true)`: Skips `null` by default (most type conversions don't need null). Set `$skipNull = false` to process null values.
1212
- **Pipeline Order:** Pipeline steps execute in the exact order defined; `required()` is a flag enforced after the pipeline regardless of position.
1313
- **Transformation Types:**
1414
- `pipe(callable)`: Intended for sanitization. Preserves type context and applies type-coercion to result.
@@ -60,7 +60,7 @@ satisfiesAll(array<callable|FieldValidator> $rules, ?string $message = null): st
6060
satisfiesNone(array<callable|FieldValidator> $rules, ?string $message = null): static
6161

6262
// Transformation
63-
transform(callable $fn): static (can change type)
63+
transform(callable $fn, bool $skipNull = true): static (can change type, skips null by default)
6464
pipe(callable ...$fns): static (preserves type, skips null)
6565
```
6666

src/Lemmon/Validator/AllowedValuesTrait.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ trait AllowedValuesTrait
2525
* @param ?string $message Optional custom error message.
2626
* @return $this
2727
*/
28-
public function in(array $values, null|string $message = null): self
28+
public function in(array $values, ?string $message = null): self
2929
{
3030
return $this->satisfies(
3131
static fn ($value) => in_array($value, $values, true),
@@ -43,7 +43,7 @@ public function in(array $values, null|string $message = null): self
4343
*
4444
* @deprecated Use in() instead.
4545
*/
46-
public function oneOf(array $values, null|string $message = null): self
46+
public function oneOf(array $values, ?string $message = null): self
4747
{
4848
return $this->in($values, $message);
4949
}

src/Lemmon/Validator/ArrayValidator.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
class ArrayValidator extends FieldValidator
88
{
9-
private null|FieldValidator $itemValidator = null;
9+
private ?FieldValidator $itemValidator = null;
1010
private bool $filterEmpty = false;
1111

1212
/**
@@ -47,7 +47,7 @@ public function filterEmpty(): self
4747
* @param null|string $message Custom error message
4848
* @return $this
4949
*/
50-
public function notEmpty(null|string $message = null): static
50+
public function notEmpty(?string $message = null): static
5151
{
5252
return $this->minItems(1, $message ?? 'Value must not be empty');
5353
}
@@ -59,7 +59,7 @@ public function notEmpty(null|string $message = null): static
5959
* @param null|string $message Custom error message
6060
* @return $this
6161
*/
62-
public function minItems(int $min, null|string $message = null): static
62+
public function minItems(int $min, ?string $message = null): static
6363
{
6464
return $this->satisfies(
6565
static fn ($value, $key = null, $input = null) => count($value) >= $min,
@@ -74,7 +74,7 @@ public function minItems(int $min, null|string $message = null): static
7474
* @param null|string $message Custom error message
7575
* @return $this
7676
*/
77-
public function maxItems(int $max, null|string $message = null): static
77+
public function maxItems(int $max, ?string $message = null): static
7878
{
7979
return $this->satisfies(
8080
static fn ($value, $key = null, $input = null) => count($value) <= $max,
@@ -89,7 +89,7 @@ public function maxItems(int $max, null|string $message = null): static
8989
* @param null|string $message Custom error message
9090
* @return $this
9191
*/
92-
public function contains(mixed $valueOrValidator, null|string $message = null): static
92+
public function contains(mixed $valueOrValidator, ?string $message = null): static
9393
{
9494
return $this->satisfies(
9595
static function ($value, $key = null, $input = null) use ($valueOrValidator) {

src/Lemmon/Validator/FieldValidator.php

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ abstract class FieldValidator
1010
protected bool $hasDefault = false;
1111
protected bool $coerce = false;
1212
protected bool $required = false;
13-
protected null|string $requiredMessage = null;
13+
protected ?string $requiredMessage = null;
1414

1515
/**
1616
* Creates a deep copy of the validator, including pipeline closures bound to the clone.
@@ -29,15 +29,15 @@ public function clone(): static
2929
/**
3030
* Current type context for transformations (null = original validator type)
3131
*/
32-
protected null|string $currentType = null;
32+
protected ?string $currentType = null;
3333

3434
/**
3535
* Marks the field as required.
3636
*
3737
* @param string|null $message Custom error message for required validation
3838
* @return $this
3939
*/
40-
public function required(null|string $message = null): self
40+
public function required(?string $message = null): self
4141
{
4242
$this->required = true;
4343
$this->requiredMessage = $message ?? 'Value is required';
@@ -92,10 +92,8 @@ public function nullifyEmpty(): self
9292
* @param ?string $message Optional custom error message. If not provided, a generic message is used.
9393
* @return $this
9494
*/
95-
public function satisfies(
96-
callable|FieldValidator $validation,
97-
null|string $message = null,
98-
): self {
95+
public function satisfies(callable|FieldValidator $validation, ?string $message = null): self
96+
{
9997
$rule = $validation instanceof FieldValidator
10098
// Convert FieldValidator to callable
10199
? static function ($value, $key = null, $input = null) use ($validation) {
@@ -135,7 +133,7 @@ public function addValidation(callable $validation, string $message): self
135133
* @param ?string $message Custom error message.
136134
* @return $this
137135
*/
138-
public function satisfiesAll(array $validations, null|string $message = null): self
136+
public function satisfiesAll(array $validations, ?string $message = null): self
139137
{
140138
return $this->satisfies(static function ($value, $key = null, $input = null) use (
141139
$validations,
@@ -161,7 +159,7 @@ public function satisfiesAll(array $validations, null|string $message = null): s
161159
* @deprecated Use satisfiesAll() instead. Will be removed in v1.0.0.
162160
* @param array<FieldValidator|callable> $validators
163161
*/
164-
public function allOf(array $validators, null|string $message = null): self
162+
public function allOf(array $validators, ?string $message = null): self
165163
{
166164
return $this->satisfiesAll($validators, $message);
167165
}
@@ -173,7 +171,7 @@ public function allOf(array $validators, null|string $message = null): self
173171
* @param ?string $message Custom error message.
174172
* @return $this
175173
*/
176-
public function satisfiesAny(array $validations, null|string $message = null): self
174+
public function satisfiesAny(array $validations, ?string $message = null): self
177175
{
178176
return $this->satisfies(static function ($value, $key = null, $input = null) use (
179177
$validations,
@@ -199,7 +197,7 @@ public function satisfiesAny(array $validations, null|string $message = null): s
199197
* @deprecated Use satisfiesAny() instead. Will be removed in v1.0.0.
200198
* @param array<FieldValidator|callable> $validators
201199
*/
202-
public function anyOf(array $validators, null|string $message = null): self
200+
public function anyOf(array $validators, ?string $message = null): self
203201
{
204202
return $this->satisfiesAny($validators, $message);
205203
}
@@ -211,7 +209,7 @@ public function anyOf(array $validators, null|string $message = null): self
211209
* @param ?string $message Custom error message.
212210
* @return $this
213211
*/
214-
public function satisfiesNone(array $validations, null|string $message = null): self
212+
public function satisfiesNone(array $validations, ?string $message = null): self
215213
{
216214
return $this->satisfies(
217215
static function ($value, $key = null, $input = null) use ($validations) {
@@ -237,7 +235,7 @@ static function ($value, $key = null, $input = null) use ($validations) {
237235
/**
238236
* @deprecated Use satisfiesNone() instead. Will be removed in v1.0.0.
239237
*/
240-
public function not(FieldValidator $validator, null|string $message = null): self
238+
public function not(FieldValidator $validator, ?string $message = null): self
241239
{
242240
return $this->satisfiesNone(
243241
[$validator],
@@ -250,9 +248,10 @@ public function not(FieldValidator $validator, null|string $message = null): sel
250248
* Can change the type - subsequent operations work with the new type.
251249
*
252250
* @param callable $transformer The transformation function that receives the validated value.
251+
* @param bool $skipNull Whether to skip null values (default: true). Set to false to process null values.
253252
* @return $this
254253
*/
255-
public function transform(callable $transformer): self
254+
public function transform(callable $transformer, bool $skipNull = true): self
256255
{
257256
$this->pipeline[] = [
258257
'type' => PipelineType::TRANSFORMATION,
@@ -264,7 +263,7 @@ public function transform(callable $transformer): self
264263

265264
return $result; // No coercion - transform can change type
266265
},
267-
'skipNull' => false, // transform() should execute on null (can handle null and change types)
266+
'skipNull' => $skipNull,
268267
];
269268
return $this;
270269
}

src/Lemmon/Validator/IntValidator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ protected function validateType(mixed $value, string $key): mixed
4545
* @param ?string $message Custom error message.
4646
* @return static
4747
*/
48-
public function port(null|string $message = null): static
48+
public function port(?string $message = null): static
4949
{
5050
return $this->satisfies(
5151
static fn ($value, $key = null, $input = null) => (

src/Lemmon/Validator/NumericConstraintsTrait.php

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ trait NumericConstraintsTrait
1919
* @param ?string $message Custom error message.
2020
* @return static
2121
*/
22-
public function min(int|float $min, null|string $message = null): static
22+
public function min(int|float $min, ?string $message = null): static
2323
{
2424
return $this->satisfies(
2525
static fn ($value, $key = null, $input = null) => $value >= $min,
@@ -34,7 +34,7 @@ public function min(int|float $min, null|string $message = null): static
3434
* @param ?string $message Custom error message.
3535
* @return static
3636
*/
37-
public function max(int|float $max, null|string $message = null): static
37+
public function max(int|float $max, ?string $message = null): static
3838
{
3939
return $this->satisfies(
4040
static fn ($value, $key = null, $input = null) => $value <= $max,
@@ -50,7 +50,7 @@ public function max(int|float $max, null|string $message = null): static
5050
* @param ?string $message Custom error message.
5151
* @return static
5252
*/
53-
public function between(int|float $min, int|float $max, null|string $message = null): static
53+
public function between(int|float $min, int|float $max, ?string $message = null): static
5454
{
5555
return $this->satisfies(
5656
static fn ($value, $key = null, $input = null) => $value >= $min && $value <= $max,
@@ -65,7 +65,7 @@ public function between(int|float $min, int|float $max, null|string $message = n
6565
* @param ?string $message Custom error message.
6666
* @return static
6767
*/
68-
public function gt(int|float $threshold, null|string $message = null): static
68+
public function gt(int|float $threshold, ?string $message = null): static
6969
{
7070
return $this->satisfies(
7171
static fn ($value, $key = null, $input = null) => $value > $threshold,
@@ -80,7 +80,7 @@ public function gt(int|float $threshold, null|string $message = null): static
8080
* @param ?string $message Custom error message.
8181
* @return static
8282
*/
83-
public function gte(int|float $threshold, null|string $message = null): static
83+
public function gte(int|float $threshold, ?string $message = null): static
8484
{
8585
return $this->satisfies(
8686
static fn ($value, $key = null, $input = null) => $value >= $threshold,
@@ -95,7 +95,7 @@ public function gte(int|float $threshold, null|string $message = null): static
9595
* @param ?string $message Custom error message.
9696
* @return static
9797
*/
98-
public function lt(int|float $threshold, null|string $message = null): static
98+
public function lt(int|float $threshold, ?string $message = null): static
9999
{
100100
return $this->satisfies(
101101
static fn ($value, $key = null, $input = null) => $value < $threshold,
@@ -110,7 +110,7 @@ public function lt(int|float $threshold, null|string $message = null): static
110110
* @param ?string $message Custom error message.
111111
* @return static
112112
*/
113-
public function lte(int|float $threshold, null|string $message = null): static
113+
public function lte(int|float $threshold, ?string $message = null): static
114114
{
115115
return $this->satisfies(
116116
static fn ($value, $key = null, $input = null) => $value <= $threshold,
@@ -125,7 +125,7 @@ public function lte(int|float $threshold, null|string $message = null): static
125125
* @param ?string $message Custom error message.
126126
* @return static
127127
*/
128-
public function multipleOf(int|float $divisor, null|string $message = null): static
128+
public function multipleOf(int|float $divisor, ?string $message = null): static
129129
{
130130
return $this->satisfies(
131131
static function ($value, $key = null, $input = null) use ($divisor) {
@@ -148,7 +148,7 @@ static function ($value, $key = null, $input = null) use ($divisor) {
148148
* @param ?string $message Custom error message.
149149
* @return static
150150
*/
151-
public function positive(null|string $message = null): static
151+
public function positive(?string $message = null): static
152152
{
153153
return $this->satisfies(
154154
static fn ($value, $key = null, $input = null) => $value > 0,
@@ -162,7 +162,7 @@ public function positive(null|string $message = null): static
162162
* @param ?string $message Custom error message.
163163
* @return static
164164
*/
165-
public function negative(null|string $message = null): static
165+
public function negative(?string $message = null): static
166166
{
167167
return $this->satisfies(
168168
static fn ($value, $key = null, $input = null) => $value < 0,
@@ -176,7 +176,7 @@ public function negative(null|string $message = null): static
176176
* @param ?string $message Custom error message.
177177
* @return static
178178
*/
179-
public function nonNegative(null|string $message = null): static
179+
public function nonNegative(?string $message = null): static
180180
{
181181
return $this->gte(0, $message ?? 'Value must be non-negative');
182182
}
@@ -187,7 +187,7 @@ public function nonNegative(null|string $message = null): static
187187
* @param ?string $message Custom error message.
188188
* @return static
189189
*/
190-
public function nonPositive(null|string $message = null): static
190+
public function nonPositive(?string $message = null): static
191191
{
192192
return $this->lte(0, $message ?? 'Value must be non-positive');
193193
}

0 commit comments

Comments
 (0)