Skip to content

Commit 10ef96a

Browse files
committed
fix(forms): consistent treatment of empty (#63456)
Removes custom handling of emptiness in several of the validators and replaces it with a common `isEmpty` check. The common empty check considered the following values to be empty: `null`, `undefined`, `''`, `false`, `NaN` Generally most validators should treat an empty value as valid. This aligns with both the behavior or native HTML validators and reactive forms validators. As an example, consider an optional email field. If the email validator considered empty string to be an invalid email, there would be no way for the user to not enter it. There are several exceptions to this rule: - `required` whose entire purpose is to ensure that the field is *not* empty - `validateStandardSchema` which should subject all values including empty ones to the specified standard schema. It is up to the schema to decide whether an empty value is valid or not - `validate`/`validateAsync` which leaves it up to the user's custom validation logic to decide if an empty value is valid. PR Close #63456
1 parent 862495c commit 10ef96a

File tree

18 files changed

+156
-61
lines changed

18 files changed

+156
-61
lines changed

goldens/public-api/forms/signals/index.api.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,6 @@ export const REQUIRED: AggregateProperty<boolean, boolean>;
458458

459459
// @public
460460
export function required<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind> & {
461-
emptyPredicate?: (value: TValue) => boolean;
462461
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
463462
}): void;
464463

packages/forms/signals/src/api/validators/email.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import {validate} from '../logic';
1010
import {FieldPath, PathKind} from '../types';
1111
import {emailError} from '../validation_errors';
12-
import {BaseValidatorConfig, getOption} from './util';
12+
import {BaseValidatorConfig, getOption, isEmpty} from './util';
1313

1414
/**
1515
* A regular expression that matches valid e-mail addresses.
@@ -59,6 +59,9 @@ export function email<TPathKind extends PathKind = PathKind.Root>(
5959
config?: BaseValidatorConfig<string, TPathKind>,
6060
) {
6161
validate(path, (ctx) => {
62+
if (isEmpty(ctx.value())) {
63+
return undefined;
64+
}
6265
if (!EMAIL_REGEXP.test(ctx.value())) {
6366
if (config?.error) {
6467
return getOption(config.error, ctx);

packages/forms/signals/src/api/validators/max.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {aggregateProperty, property, validate} from '../logic';
1111
import {MAX} from '../property';
1212
import {FieldPath, LogicFn, PathKind} from '../types';
1313
import {maxError} from '../validation_errors';
14-
import {BaseValidatorConfig, getOption} from './util';
14+
import {BaseValidatorConfig, getOption, isEmpty} from './util';
1515

1616
/**
1717
* Binds a validator to the given path that requires the value to be less than or equal to the
@@ -36,6 +36,9 @@ export function max<TPathKind extends PathKind = PathKind.Root>(
3636
);
3737
aggregateProperty(path, MAX, ({state}) => state.property(MAX_MEMO)!());
3838
validate(path, (ctx) => {
39+
if (isEmpty(ctx.value())) {
40+
return undefined;
41+
}
3942
const max = ctx.state.property(MAX_MEMO)!();
4043
if (max === undefined || Number.isNaN(max)) {
4144
return undefined;

packages/forms/signals/src/api/validators/max_length.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import {aggregateProperty, property, validate} from '../logic';
1111
import {MAX_LENGTH} from '../property';
1212
import {FieldPath, LogicFn, PathKind} from '../types';
1313
import {maxLengthError} from '../validation_errors';
14-
import {BaseValidatorConfig, getLengthOrSize, getOption, ValueWithLengthOrSize} from './util';
14+
import {
15+
BaseValidatorConfig,
16+
getLengthOrSize,
17+
getOption,
18+
isEmpty,
19+
ValueWithLengthOrSize,
20+
} from './util';
1521

1622
/**
1723
* Binds a validator to the given path that requires the length of the value to be less than or
@@ -40,6 +46,9 @@ export function maxLength<
4046
);
4147
aggregateProperty(path, MAX_LENGTH, ({state}) => state.property(MAX_LENGTH_MEMO)!());
4248
validate(path, (ctx) => {
49+
if (isEmpty(ctx.value())) {
50+
return undefined;
51+
}
4352
const maxLength = ctx.state.property(MAX_LENGTH_MEMO)!();
4453
if (maxLength === undefined) {
4554
return undefined;

packages/forms/signals/src/api/validators/min.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {aggregateProperty, property, validate} from '../logic';
1111
import {MIN} from '../property';
1212
import {FieldPath, LogicFn, PathKind} from '../types';
1313
import {minError} from '../validation_errors';
14-
import {BaseValidatorConfig, getOption} from './util';
14+
import {BaseValidatorConfig, getOption, isEmpty} from './util';
1515

1616
/**
1717
* Binds a validator to the given path that requires the value to be greater than or equal to
@@ -36,6 +36,9 @@ export function min<TPathKind extends PathKind = PathKind.Root>(
3636
);
3737
aggregateProperty(path, MIN, ({state}) => state.property(MIN_MEMO)!());
3838
validate(path, (ctx) => {
39+
if (isEmpty(ctx.value())) {
40+
return undefined;
41+
}
3942
const min = ctx.state.property(MIN_MEMO)!();
4043
if (min === undefined || Number.isNaN(min)) {
4144
return undefined;

packages/forms/signals/src/api/validators/min_length.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import {aggregateProperty, property, validate} from '../logic';
1111
import {MIN_LENGTH} from '../property';
1212
import {FieldPath, LogicFn, PathKind} from '../types';
1313
import {minLengthError} from '../validation_errors';
14-
import {BaseValidatorConfig, getLengthOrSize, getOption, ValueWithLengthOrSize} from './util';
14+
import {
15+
BaseValidatorConfig,
16+
getLengthOrSize,
17+
getOption,
18+
isEmpty,
19+
ValueWithLengthOrSize,
20+
} from './util';
1521

1622
/**
1723
* Binds a validator to the given path that requires the length of the value to be greater than or
@@ -40,6 +46,9 @@ export function minLength<
4046
);
4147
aggregateProperty(path, MIN_LENGTH, ({state}) => state.property(MIN_LENGTH_MEMO)!());
4248
validate(path, (ctx) => {
49+
if (isEmpty(ctx.value())) {
50+
return undefined;
51+
}
4352
const minLength = ctx.state.property(MIN_LENGTH_MEMO)!();
4453
if (minLength === undefined) {
4554
return undefined;

packages/forms/signals/src/api/validators/pattern.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {aggregateProperty, property, validate} from '../logic';
1111
import {PATTERN} from '../property';
1212
import {FieldPath, LogicFn, PathKind} from '../types';
1313
import {patternError} from '../validation_errors';
14-
import {BaseValidatorConfig, getOption} from './util';
14+
import {BaseValidatorConfig, getOption, isEmpty} from './util';
1515

1616
/**
1717
* Binds a validator to the given path that requires the value to match a specific regex pattern.
@@ -35,14 +35,13 @@ export function pattern<TPathKind extends PathKind = PathKind.Root>(
3535
);
3636
aggregateProperty(path, PATTERN, ({state}) => state.property(PATTERN_MEMO)!());
3737
validate(path, (ctx) => {
38+
if (isEmpty(ctx.value())) {
39+
return undefined;
40+
}
3841
const pattern = ctx.state.property(PATTERN_MEMO)!();
39-
40-
// A pattern validator should not fail on an empty value. This matches the behavior of HTML's
41-
// built in `pattern` attribute.
42-
if (pattern === undefined || ctx.value() == null || ctx.value() === '') {
42+
if (pattern === undefined) {
4343
return undefined;
4444
}
45-
4645
if (!pattern.test(ctx.value())) {
4746
if (config?.error) {
4847
return getOption(config.error, ctx);

packages/forms/signals/src/api/validators/required.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {aggregateProperty, property, validate} from '../logic';
1111
import {REQUIRED} from '../property';
1212
import {FieldPath, LogicFn, PathKind} from '../types';
1313
import {requiredError} from '../validation_errors';
14-
import {BaseValidatorConfig, getOption} from './util';
14+
import {BaseValidatorConfig, getOption, isEmpty} from './util';
1515

1616
/**
1717
* Binds a validator to the given path that requires the value to be non-empty.
@@ -23,28 +23,22 @@ import {BaseValidatorConfig, getOption} from './util';
2323
* - `message`: A user-facing message for the error.
2424
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.required()`
2525
* or a function that receives the `FieldContext` and returns custom validation error(s).
26-
* - `emptyPredicate`: A function that receives the value, and returns `true` if it is considered empty.
27-
* By default `false`, `''`, `null`, and `undefined` are considered empty
2826
* - `when`: A function that receives the `FieldContext` and returns true if the field is required
2927
* @template TValue The type of value stored in the field the logic is bound to.
3028
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
3129
*/
3230
export function required<TValue, TPathKind extends PathKind = PathKind.Root>(
3331
path: FieldPath<TValue, TPathKind>,
3432
config?: BaseValidatorConfig<TValue, TPathKind> & {
35-
emptyPredicate?: (value: TValue) => boolean;
3633
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
3734
},
3835
): void {
39-
const emptyPredicate =
40-
config?.emptyPredicate ?? ((value) => value === false || value == null || value === '');
41-
4236
const REQUIRED_MEMO = property(path, (ctx) =>
4337
computed(() => (config?.when ? config.when(ctx) : true)),
4438
);
4539
aggregateProperty(path, REQUIRED, ({state}) => state.property(REQUIRED_MEMO)!());
4640
validate(path, (ctx) => {
47-
if (ctx.state.property(REQUIRED_MEMO)!() && emptyPredicate(ctx.value())) {
41+
if (ctx.state.property(REQUIRED_MEMO)!() && isEmpty(ctx.value())) {
4842
if (config?.error) {
4943
return getOption(config.error, ctx);
5044
} else {

packages/forms/signals/src/api/validators/util.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,13 @@ export function getOption<TOption, TValue, TPathKind extends PathKind = PathKind
5050
): TOption | undefined {
5151
return opt instanceof Function ? opt(ctx) : opt;
5252
}
53+
54+
/**
55+
* Checks if the given value is considered empty. Empty values are: null, undefined, '', false, NaN.
56+
*/
57+
export function isEmpty(value: unknown): boolean {
58+
if (typeof value === 'number') {
59+
return isNaN(value);
60+
}
61+
return value === '' || value === false || value == null;
62+
}

packages/forms/signals/test/node/api/validators/email.spec.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('email validator', () => {
3030
});
3131

3232
it('supports custom errors', () => {
33-
const cat = signal({name: 'pirojok-the-cat', email: ''});
33+
const cat = signal({name: 'pirojok-the-cat', email: 'error'});
3434
const f = form(
3535
cat,
3636
(p) => {
@@ -52,7 +52,7 @@ describe('email validator', () => {
5252
});
5353

5454
it('supports custom error message', () => {
55-
const cat = signal({name: 'pirojok-the-cat', email: ''});
55+
const cat = signal({name: 'pirojok-the-cat', email: 'error'});
5656
const f = form(
5757
cat,
5858
(p) => {
@@ -72,4 +72,19 @@ describe('email validator', () => {
7272
}),
7373
]);
7474
});
75+
76+
it('should treat empty value as valid', () => {
77+
const model = signal('');
78+
const f = form(
79+
model,
80+
(p) => {
81+
email(p);
82+
},
83+
{
84+
injector: TestBed.inject(Injector),
85+
},
86+
);
87+
88+
expect(f().errors()).toEqual([]);
89+
});
7590
});

0 commit comments

Comments
 (0)