From 9bdcd9b57f0ef38f3f495f785aec830c138b0bac Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 20 Dec 2025 02:51:20 +0100 Subject: [PATCH] Add predicate factory mode to `is.any` and `is.all` Fixes #218 --- readme.md | 47 ++++++++++++++++++++ source/index.ts | 101 ++++++++++++++++++++++++++++++++++++------- test/test.ts | 113 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 16 deletions(-) diff --git a/readme.md b/readme.md index 9a4982c..6965c9b 100644 --- a/readme.md +++ b/readme.md @@ -575,6 +575,31 @@ is.any([is.boolean, is.number], 'unicorns', [], new Map()); //=> false ``` +##### .any(predicate[]) + +Using an array of `predicate[]` without values, returns a combined type guard that checks if a value matches **any** of the predicates: + +```js +const isStringOrNumber = is.any([is.string, is.number]); + +isStringOrNumber('hello'); +//=> true + +isStringOrNumber(123); +//=> true + +isStringOrNumber(true); +//=> false +``` + +This is useful for composing with other methods like `is.optional`: + +```js +is.optional(value, is.any([is.string, is.number])); +``` + +An empty predicate array currently returns a predicate that always returns `false`. This will throw in the next major release. + ##### .all(predicate, ...values) Returns `true` if **all** of the input `values` returns true in the `predicate`: @@ -587,6 +612,28 @@ is.all(is.string, '🦄', [], 'unicorns'); //=> false ``` +##### .all(predicate[]) + +Using an array of `predicate[]` without values, returns a combined type guard that checks if a value matches **all** of the predicates: + +```js +const isArrayAndNonEmpty = is.all([is.array, is.nonEmptyArray]); + +isArrayAndNonEmpty(['hello']); +//=> true + +isArrayAndNonEmpty([]); +//=> false +``` + +This is useful for composing with other methods like `is.optional`: + +```js +is.optional(value, is.all([is.object, is.plainObject])); +``` + +An empty predicate array currently returns a predicate that always returns `true`. This will throw in the next major release. + ##### .optional(value, predicate) Returns `true` if `value` is `undefined` or satisfies the given `predicate`. diff --git a/source/index.ts b/source/index.ts index f5c8fdd..77e9d2f 100644 --- a/source/index.ts +++ b/source/index.ts @@ -332,15 +332,77 @@ function isAbsoluteModule2(remainder: 0 | 1) { return (value: unknown): value is number => isInteger(value) && Math.abs(value % 2) === remainder; } -export function isAll(predicate: Predicate, ...values: unknown[]): boolean { - return predicateOnArray(Array.prototype.every, predicate, values); +type TypeGuard = (value: unknown) => value is T; + +function validatePredicateArray(predicateArray: readonly Predicate[], allowEmpty: boolean) { + if (predicateArray.length === 0) { + if (allowEmpty) { + // Next major release: throw for empty predicate arrays to avoid vacuous results. + // throw new TypeError('Invalid predicate array'); + } else { + throw new TypeError('Invalid predicate array'); + } + + return; + } + + for (const predicate of predicateArray) { + if (!isFunction(predicate)) { + throw new TypeError(`Invalid predicate: ${JSON.stringify(predicate)}`); + } + } } -export function isAny(predicate: Predicate | Predicate[], ...values: unknown[]): boolean { - const predicates = isArray(predicate) ? predicate : [predicate]; - return predicates.some(singlePredicate => - predicateOnArray(Array.prototype.some, singlePredicate, values), - ); +// Predicate factory overloads - return a type guard when called with only predicates +export function isAll(predicates: [TypeGuard]): TypeGuard; +export function isAll(predicates: [TypeGuard, TypeGuard]): TypeGuard; +export function isAll(predicates: [TypeGuard, TypeGuard, TypeGuard]): TypeGuard; +export function isAll(predicates: [TypeGuard, TypeGuard, TypeGuard, TypeGuard]): TypeGuard; +export function isAll(predicates: [TypeGuard, TypeGuard, TypeGuard, TypeGuard, TypeGuard]): TypeGuard; +export function isAll(predicates: ReadonlyArray>): TypeGuard; +export function isAll(predicates: readonly Predicate[]): Predicate; +// Evaluator overload - check if all values match the predicate +export function isAll(predicate: Predicate | readonly Predicate[], ...values: unknown[]): boolean; +export function isAll(predicate: Predicate | readonly Predicate[], ...values: unknown[]): boolean | Predicate { + if (Array.isArray(predicate)) { + const predicateArray = predicate as readonly Predicate[]; + validatePredicateArray(predicateArray, values.length === 0); + + const combinedPredicate = (value: unknown) => predicateArray.every(singlePredicate => singlePredicate(value)); + if (values.length === 0) { + return combinedPredicate; + } + + return predicateOnArray(Array.prototype.every, combinedPredicate, values); + } + + return predicateOnArray(Array.prototype.every, predicate as Predicate, values); +} + +// Predicate factory overloads - return a type guard when called with only predicates +export function isAny(predicates: [TypeGuard]): TypeGuard; +export function isAny(predicates: [TypeGuard, TypeGuard]): TypeGuard; +export function isAny(predicates: [TypeGuard, TypeGuard, TypeGuard]): TypeGuard; +export function isAny(predicates: [TypeGuard, TypeGuard, TypeGuard, TypeGuard]): TypeGuard; +export function isAny(predicates: [TypeGuard, TypeGuard, TypeGuard, TypeGuard, TypeGuard]): TypeGuard; +export function isAny(predicates: ReadonlyArray>): TypeGuard; +export function isAny(predicates: readonly Predicate[]): Predicate; +// Evaluator overload - check if any value matches any predicate +export function isAny(predicate: Predicate | readonly Predicate[], ...values: unknown[]): boolean; +export function isAny(predicate: Predicate | readonly Predicate[], ...values: unknown[]): boolean | Predicate { + if (Array.isArray(predicate)) { + const predicateArray = predicate as readonly Predicate[]; + validatePredicateArray(predicateArray, values.length === 0); + + const combinedPredicate = (value: unknown) => predicateArray.some(singlePredicate => singlePredicate(value)); + if (values.length === 0) { + return combinedPredicate; + } + + return predicateOnArray(Array.prototype.some, combinedPredicate, values); + } + + return predicateOnArray(Array.prototype.some, predicate as Predicate, values); } export function isOptional(value: unknown, predicate: (value: unknown) => value is T): value is T | undefined { @@ -715,8 +777,6 @@ export function isTruthy(value: T | Falsy): value is T { return Boolean(value); } -type TypeGuard = (value: unknown) => value is T; - // eslint-disable-next-line @typescript-eslint/ban-types type ResolveTypesOfTypeGuardsTuple = TypeGuardsOfT extends [TypeGuard, ...infer TOthers] @@ -943,8 +1003,8 @@ type Assert = { inRange: (value: number, range: number | [number, number], message?: string) => asserts value is number; // Variadic functions. - any: (predicate: Predicate | Predicate[], ...values: unknown[]) => void | never; - all: (predicate: Predicate, ...values: unknown[]) => void | never; + any: (predicate: Predicate | readonly Predicate[], ...values: unknown[]) => void | never; + all: (predicate: Predicate | readonly Predicate[], ...values: unknown[]) => void | never; /** Asserts that `value` is `undefined` or satisfies the provided `assertion`. @@ -1146,17 +1206,26 @@ function isIsMethodName(value: unknown): value is IsMethodName { return isMethodNames.includes(value as IsMethodName); } -export function assertAll(predicate: Predicate, ...values: unknown[]): void | never { +export function assertAll(predicate: Predicate | readonly Predicate[], ...values: unknown[]): void | never { + if (values.length === 0) { + throw new TypeError('Invalid number of values'); + } + if (!isAll(predicate, ...values)) { - const expectedType = isIsMethodName(predicate.name) ? methodTypeMap[predicate.name] : 'predicate returns truthy for all values'; + const predicateFunction = predicate as Predicate; + const expectedType = !Array.isArray(predicate) && isIsMethodName(predicateFunction.name) ? methodTypeMap[predicateFunction.name] : 'predicate returns truthy for all values'; throw new TypeError(typeErrorMessageMultipleValues(expectedType, values)); } } -export function assertAny(predicate: Predicate | Predicate[], ...values: unknown[]): void | never { +export function assertAny(predicate: Predicate | readonly Predicate[], ...values: unknown[]): void | never { + if (values.length === 0) { + throw new TypeError('Invalid number of values'); + } + if (!isAny(predicate, ...values)) { - const predicates = isArray(predicate) ? predicate : [predicate]; - const expectedTypes = predicates.map(predicate => isIsMethodName(predicate.name) ? methodTypeMap[predicate.name] : 'predicate returns truthy for any value'); + const predicates = Array.isArray(predicate) ? predicate as readonly Predicate[] : [predicate as Predicate]; + const expectedTypes = predicates.map(singlePredicate => isIsMethodName(singlePredicate.name) ? methodTypeMap[singlePredicate.name] : 'predicate returns truthy for any value'); throw new TypeError(typeErrorMessageMultipleValues(expectedTypes, values)); } } diff --git a/test/test.ts b/test/test.ts index 4da5723..03d535c 100644 --- a/test/test.ts +++ b/test/test.ts @@ -1648,12 +1648,17 @@ test('is.any', t => { t.false(is.any(is.integer, true, 'lol', {})); t.true(is.any([is.string, is.number], {}, true, '🦄')); t.false(is.any([is.boolean, is.number], 'unicorns', [], new Map())); + t.is(typeof is.any([is.string, is.number]), 'function'); t.throws(() => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument is.any(null as any, true); }); + t.throws(() => { + is.any([], 'value'); + }); + t.throws(() => { is.any(is.string); }); @@ -1666,6 +1671,10 @@ test('is.any', t => { assert.any(is.object, false, {}, 'unicorns'); }); + t.throws(() => { + assert.any([is.string, is.number]); + }); + t.throws(() => { assert.any(is.boolean, '🦄', [], 3); }); @@ -1679,6 +1688,10 @@ test('is.any', t => { assert.any(null as any, true); }); + t.throws(() => { + assert.any([], 'value'); + }); + t.throws(() => { assert.any(is.string); }); @@ -1726,12 +1739,18 @@ test('is.all', t => { t.false(is.all(is.set, new Map(), {})); t.true(is.all(is.array, ['1'], ['2'])); + t.true(is.all([is.string, is.nonEmptyString], '🦄', 'unicorns')); + t.false(is.all([is.string, is.number], '🦄')); t.throws(() => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument is.all(null as any, true); }); + t.throws(() => { + is.all([], 'value'); + }); + t.throws(() => { is.all(is.string); }); @@ -1744,10 +1763,22 @@ test('is.all', t => { assert.all(is.boolean, true, false); }); + t.throws(() => { + assert.all([is.string, is.number]); + }); + + t.notThrows(() => { + assert.all([is.string, is.nonEmptyString], '🦄', 'unicorns'); + }); + t.throws(() => { assert.all(is.string, '🦄', []); }); + t.throws(() => { + assert.all([is.string, is.number], '🦄'); + }); + t.throws(() => { assert.all(is.set, new Map(), {}); }); @@ -1757,6 +1788,10 @@ test('is.all', t => { assert.all(null as any, true); }); + t.throws(() => { + assert.all([], 'value'); + }); + t.throws(() => { assert.all(is.string); }); @@ -1783,6 +1818,84 @@ test('is.all', t => { }); }); +test('is.any as predicate factory', t => { + // Returns a type guard function when called with only predicates + const isStringOrNumber = is.any([is.string, is.number]); + t.is(typeof isStringOrNumber, 'function'); + t.true(isStringOrNumber('hello')); + t.true(isStringOrNumber(123)); + t.false(isStringOrNumber(true)); + t.false(isStringOrNumber({})); + + // Type narrowing works correctly + const value: unknown = 'test'; + if (isStringOrNumber(value)) { + // TypeScript should narrow to string | number + const narrowed: string | number = value; + t.pass(`narrowed to: ${typeof narrowed}`); + } + + // Works with is.optional + t.true(is.optional(undefined, is.any([is.string, is.number]))); + t.true(is.optional('test', is.any([is.string, is.number]))); + t.true(is.optional(42, is.any([is.string, is.number]))); + t.false(is.optional(true, is.any([is.string, is.number]))); + + const predicateArray: Predicate[] = [is.string, is.number]; + const isStringOrNumberFromArray = is.any(predicateArray); + t.is(typeof isStringOrNumberFromArray, 'function'); + t.true(isStringOrNumberFromArray('hello')); + t.true(isStringOrNumberFromArray(123)); + t.false(isStringOrNumberFromArray(true)); + + // Type narrowing with is.optional + const optionalValue: unknown = undefined; + if (is.optional(optionalValue, is.any([is.string, is.number]))) { + // TypeScript should narrow to string | number | undefined + const narrowed: string | number | undefined = optionalValue; + t.pass(`optional narrowed to: ${typeof narrowed}`); + } + + // Works with more predicates + const isStringOrNumberOrBoolean = is.any([is.string, is.number, is.boolean]); + t.true(isStringOrNumberOrBoolean('hello')); + t.true(isStringOrNumberOrBoolean(123)); + t.true(isStringOrNumberOrBoolean(true)); + t.false(isStringOrNumberOrBoolean({})); + + t.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + is.any([is.string, 123 as any]); + }); +}); + +test('is.all as predicate factory', t => { + // Returns a type guard function when called with only predicates + const isArrayAndNonEmpty = is.all([is.array, is.nonEmptyArray]); + t.is(typeof isArrayAndNonEmpty, 'function'); + t.true(isArrayAndNonEmpty(['hello'])); + t.false(isArrayAndNonEmpty([])); + t.false(isArrayAndNonEmpty('hello')); + + // Type narrowing works correctly + const value: unknown = ['test']; + if (isArrayAndNonEmpty(value)) { + // TypeScript should narrow to the intersection type + t.true(Array.isArray(value)); + t.true(value.length > 0); + } + + // Works with is.optional + t.true(is.optional(undefined, is.all([is.object, is.plainObject]))); + t.true(is.optional({foo: 'bar'}, is.all([is.object, is.plainObject]))); + t.false(is.optional([], is.all([is.object, is.plainObject]))); + + t.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + is.all([is.string, 123 as any]); + }); +}); + test('is.formData supplemental', t => { const data = new window.FormData(); t.true(is.formData(data));