diff --git a/bench/hasAdditionalProperties.bench.ts b/bench/hasAdditionalProperties.bench.ts new file mode 100644 index 0000000..9cfbd5c --- /dev/null +++ b/bench/hasAdditionalProperties.bench.ts @@ -0,0 +1,23 @@ +import { bench } from 'vitest'; + +import { hasAdditionalProperties } from '../src/hasAdditionalProperties.js'; + +const input = { + name: 'Test', + age: 100_000, + job: { + title: 'Tester', + }, +}; + +const input2 = { + ...input, + extra: true, +}; + +const knownProperties = ['name', 'age', 'job']; + +bench('hasAdditionalProperties()', () => { + hasAdditionalProperties(input, knownProperties); + hasAdditionalProperties(input2, knownProperties); +}); diff --git a/src/hasAdditionalProperties.ts b/src/hasAdditionalProperties.ts new file mode 100644 index 0000000..a9b2633 --- /dev/null +++ b/src/hasAdditionalProperties.ts @@ -0,0 +1,8 @@ +/** + * Determine if the `input` object has properties other than the provided `knownProperties`. + */ +export const hasAdditionalProperties = (input: object, knownProperties: PropertyKey[]): boolean => { + const properties = Array.isArray(input) ? [...knownProperties.map(String), 'length'] : knownProperties; + + return !Reflect.ownKeys(input).every((key) => properties.includes(key)); +}; diff --git a/src/index.ts b/src/index.ts index 13323e2..822fdc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ export * from './getPackageName.js'; export * from './getPaths.js'; export * from './getRandomItem.js'; export * from './getType.js'; +export * from './hasAdditionalProperties.js'; export * from './inRange.js'; export * from './isEmpty.js'; export * from './iterateForever.js'; diff --git a/src/predicate/assume.ts b/src/predicate/assume.ts new file mode 100644 index 0000000..9ae11cf --- /dev/null +++ b/src/predicate/assume.ts @@ -0,0 +1,10 @@ +/** + * This function is used to create a type guard that always returns true, effectively allowing + * the input to be treated as the specified type without any runtime checks. + * + * @privateRemarks + * The `is` prefix is omitted because there is not any conditional logic in the function. + * It is more of a command to `tsc` to assume the type rather than a check. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any +export const assume = (_input: unknown, ..._args: any[]): _input is Type => true; diff --git a/src/predicate/factory/is.ts b/src/predicate/factory/is.ts index a699859..f32ac5c 100644 --- a/src/predicate/factory/is.ts +++ b/src/predicate/factory/is.ts @@ -1,7 +1,9 @@ import type { TypePredicateFn } from '../../types/functions.js'; -import type { Pretty, Writable } from '../../types/utils.js'; +/** + * Use `Object.is()` to compare `input` against allowed values. + */ export const is = - (...options: T): TypePredicateFn>> => + (...values: T): TypePredicateFn => (input: unknown): input is T[number] => - options.some((option) => Object.is(input, option)); + values.some((value) => Object.is(input, value)); diff --git a/src/predicate/factory/shape.ts b/src/predicate/factory/shape.ts index 0f4f9c0..6cf237a 100644 --- a/src/predicate/factory/shape.ts +++ b/src/predicate/factory/shape.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/ban-types */ +import { hasAdditionalProperties } from '../../hasAdditionalProperties.js'; import { isAnyObject } from '../isAnyObject.js'; import { literal } from './literal.js'; @@ -40,24 +41,26 @@ export type InferTypeFromShape> = { export const shape = = ObjectShapeRecord>( objectShape: Shape, + additionalProperties = true, ): TypePredicateFn>> => { - const entries: [key: string | symbol, predicate: TypePredicateFn][] = Reflect.ownKeys(objectShape).map( - (key) => { - const value = Reflect.get(objectShape, key); - - const predicate = - typeof value === 'function' - ? (value as TypePredicateFn) - : value instanceof RegExp - ? matching(value) - : isAnyObject(value) - ? shape(value) - : literal(value); - - return [key, predicate]; - }, - ); + const knownProperties = Reflect.ownKeys(objectShape); + const entries: [key: string | symbol, predicate: TypePredicateFn][] = knownProperties.map((key) => { + const value = Reflect.get(objectShape, key); + + const predicate = + typeof value === 'function' + ? (value as TypePredicateFn) + : value instanceof RegExp + ? matching(value) + : isAnyObject(value) + ? shape(value) + : literal(value); + + return [key, predicate]; + }); return (input: unknown): input is Pretty> => - isAnyObject(input) && entries.every(([key, predicate]) => predicate(Reflect.get(input, key))); + isAnyObject(input) && + entries.every(([key, predicate]) => predicate(Reflect.get(input, key))) && + (additionalProperties || !hasAdditionalProperties(input, knownProperties)); }; diff --git a/src/predicate/index.ts b/src/predicate/index.ts index b7b66d4..97469fe 100644 --- a/src/predicate/index.ts +++ b/src/predicate/index.ts @@ -1,3 +1,4 @@ +export * from './assume.js'; export * from './isAny.js'; export * from './isAnyObject.js'; export * from './isAnyObjectWith.js'; diff --git a/src/predicate/isAny.ts b/src/predicate/isAny.ts index 3158f88..f4d7905 100644 --- a/src/predicate/isAny.ts +++ b/src/predicate/isAny.ts @@ -1,2 +1,4 @@ +import { assume } from './assume.js'; + /* eslint-disable @typescript-eslint/no-explicit-any */ -export const isAny = (_input: unknown): _input is any => true; +export const isAny = assume; diff --git a/src/predicate/isUnknown.ts b/src/predicate/isUnknown.ts index 4eafb1e..0d4a78a 100644 --- a/src/predicate/isUnknown.ts +++ b/src/predicate/isUnknown.ts @@ -1 +1,3 @@ -export const isUnknown = (_input: unknown): _input is unknown => true; +import { assume } from './assume.js'; + +export const isUnknown = assume; diff --git a/test/hasAdditionalProperties.test.ts b/test/hasAdditionalProperties.test.ts new file mode 100644 index 0000000..dfda856 --- /dev/null +++ b/test/hasAdditionalProperties.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { hasAdditionalProperties } from '../src/hasAdditionalProperties.js'; + +describe('hasAdditionalProperties()', () => { + it('returns true when additional properties are detected', () => { + expect(hasAdditionalProperties({ a: 1, b: 2 }, ['a'])).toBeTruthy(); + }); + + it('returns false when additional properties are not detected', () => { + expect(hasAdditionalProperties({ a: 1, [Symbol.for('b')]: 2 }, ['a', Symbol.for('b')])).toBeFalsy(); + }); + + it('automatically allows `length` when the input is an array', () => { + expect(hasAdditionalProperties([0, 1], [0])).toBeTruthy(); + expect(hasAdditionalProperties([0, 1], [0, 'length'])).toBeTruthy(); + + expect(hasAdditionalProperties([0, 1], [0, 1])).toBeFalsy(); + expect(hasAdditionalProperties([0, 1], [0, 1, 'length'])).toBeFalsy(); + }); +}); diff --git a/test/predicate/assume.test.ts b/test/predicate/assume.test.ts new file mode 100644 index 0000000..25d3946 --- /dev/null +++ b/test/predicate/assume.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; + +import { assume } from '../../src/predicate/assume.js'; + +describe('assume()', () => { + it.each([1, 'a', true, null, {}, undefined, new Date()])('Always Returns true: %j', (input) => { + expect(assume(input)).toBeTruthy(); + }); +}); diff --git a/test/predicate/factory/assume.test-d.ts b/test/predicate/factory/assume.test-d.ts new file mode 100644 index 0000000..b906c9f --- /dev/null +++ b/test/predicate/factory/assume.test-d.ts @@ -0,0 +1,25 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import { assume } from '../../../src/predicate/assume.js'; + +import type { TypePredicateFn } from '../../../src/types/functions.js'; + +describe('assume()', () => { + it('Is a type predicate function', () => { + expectTypeOf(assume).toBeFunction(); + expectTypeOf(assume).parameter(0).toEqualTypeOf(); + + expectTypeOf(assume).toEqualTypeOf>(); + expectTypeOf(assume).toEqualTypeOf>(); + expectTypeOf(assume).toEqualTypeOf>(); + expectTypeOf(assume).toEqualTypeOf>(); + expectTypeOf(assume).toEqualTypeOf>(); + expectTypeOf(assume).toEqualTypeOf>(); + expectTypeOf(assume).toEqualTypeOf>(); + expectTypeOf(assume).toEqualTypeOf>(); + // eslint-disable-next-line @typescript-eslint/ban-types + expectTypeOf(assume).toEqualTypeOf>(); + expectTypeOf(assume).toEqualTypeOf>(); + expectTypeOf(assume).toEqualTypeOf>(); + }); +}); diff --git a/test/predicate/factory/shape.test.ts b/test/predicate/factory/shape.test.ts index 2e3e321..1692c6e 100644 --- a/test/predicate/factory/shape.test.ts +++ b/test/predicate/factory/shape.test.ts @@ -23,7 +23,9 @@ describe('shape()', () => { type User = { name: Name; - title: string; + job: { + title: string; + }; role: Role; value: number; age?: number; @@ -39,7 +41,9 @@ describe('shape()', () => { const userShape = { name: isName, - title: /software/i, + job: { + title: /\bsoftware\b/i, + }, role: Role.User, value: range(0, 100), [valueSymbol]: range(0, 100_000), @@ -53,19 +57,21 @@ describe('shape()', () => { tuple: ['PI', Math.PI], } satisfies UserShape; - const fn = shape(userShape); - it('Returns a type predicate function', () => { - expect(fn).instanceOf(Function); + expect(shape(userShape)).instanceOf(Function); }); it('Checks string and symbol properties', () => { + const fn = shape(userShape); + expect(fn({})).toBeFalsy(); expect( fn({ name: 'Test Testerson', - title: 'Software Engineer', + job: { + title: 'Software Engineer', + }, role: Role.User, value: 100, [valueSymbol]: 100_000, @@ -78,7 +84,9 @@ describe('shape()', () => { expect( fn({ name: 'Test Testerson', - title: 'Senior Software Engineer', + job: { + title: 'Senior Software Engineer', + }, role: Role.User, value: 100, [valueSymbol]: 100_000, @@ -91,4 +99,26 @@ describe('shape()', () => { }), ).toBeTruthy(); }); + + it('Can check for additional properties', () => { + const fn = shape( + { + name: isString, + }, + false, + ); + + expect( + fn({ + name: 'Test Testerson', + }), + ).toBeTruthy(); + + expect( + fn({ + name: 'Test Testerson', + extra: 'not allowed', + }), + ).toBeFalsy(); + }); });