Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions bench/hasAdditionalProperties.bench.ts
Original file line number Diff line number Diff line change
@@ -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);
});
8 changes: 8 additions & 0 deletions src/hasAdditionalProperties.ts
Original file line number Diff line number Diff line change
@@ -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));
};
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 10 additions & 0 deletions src/predicate/assume.ts
Original file line number Diff line number Diff line change
@@ -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 = <Type>(_input: unknown, ..._args: any[]): _input is Type => true;
8 changes: 5 additions & 3 deletions src/predicate/factory/is.ts
Original file line number Diff line number Diff line change
@@ -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 =
<const T extends unknown[]>(...options: T): TypePredicateFn<Pretty<Writable<T[number]>>> =>
<const T extends unknown[]>(...values: T): TypePredicateFn<T[number]> =>
(input: unknown): input is T[number] =>
options.some((option) => Object.is(input, option));
values.some((value) => Object.is(input, value));
37 changes: 20 additions & 17 deletions src/predicate/factory/shape.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -40,24 +41,26 @@ export type InferTypeFromShape<Shape extends ObjectShapeRecord<object>> = {

export const shape = <Type extends object, Shape extends ObjectShapeRecord<Type> = ObjectShapeRecord<Type>>(
objectShape: Shape,
additionalProperties = true,
): TypePredicateFn<Pretty<Type & InferTypeFromShape<Shape>>> => {
const entries: [key: string | symbol, predicate: TypePredicateFn<unknown>][] = Reflect.ownKeys(objectShape).map(
(key) => {
const value = Reflect.get(objectShape, key);

const predicate =
typeof value === 'function'
? (value as TypePredicateFn<unknown>)
: 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<unknown>][] = knownProperties.map((key) => {
const value = Reflect.get(objectShape, key);

const predicate =
typeof value === 'function'
? (value as TypePredicateFn<unknown>)
: value instanceof RegExp
? matching(value)
: isAnyObject(value)
? shape(value)
: literal(value);

return [key, predicate];
});

return (input: unknown): input is Pretty<Type & InferTypeFromShape<Shape>> =>
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));
};
1 change: 1 addition & 0 deletions src/predicate/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './assume.js';
export * from './isAny.js';
export * from './isAnyObject.js';
export * from './isAnyObjectWith.js';
Expand Down
4 changes: 3 additions & 1 deletion src/predicate/isAny.ts
Original file line number Diff line number Diff line change
@@ -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<any>;
4 changes: 3 additions & 1 deletion src/predicate/isUnknown.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const isUnknown = (_input: unknown): _input is unknown => true;
import { assume } from './assume.js';

export const isUnknown = assume<unknown>;
21 changes: 21 additions & 0 deletions test/hasAdditionalProperties.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
9 changes: 9 additions & 0 deletions test/predicate/assume.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
25 changes: 25 additions & 0 deletions test/predicate/factory/assume.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>();

expectTypeOf(assume<string>).toEqualTypeOf<TypePredicateFn<string>>();
expectTypeOf(assume<number>).toEqualTypeOf<TypePredicateFn<number>>();
expectTypeOf(assume<bigint>).toEqualTypeOf<TypePredicateFn<bigint>>();
expectTypeOf(assume<boolean>).toEqualTypeOf<TypePredicateFn<boolean>>();
expectTypeOf(assume<symbol>).toEqualTypeOf<TypePredicateFn<symbol>>();
expectTypeOf(assume<undefined>).toEqualTypeOf<TypePredicateFn<undefined>>();
expectTypeOf(assume<null>).toEqualTypeOf<TypePredicateFn<null>>();
expectTypeOf(assume<object>).toEqualTypeOf<TypePredicateFn<object>>();
// eslint-disable-next-line @typescript-eslint/ban-types
expectTypeOf(assume<Function>).toEqualTypeOf<TypePredicateFn<Function>>();
expectTypeOf(assume<unknown>).toEqualTypeOf<TypePredicateFn<unknown>>();
expectTypeOf(assume<string[]>).toEqualTypeOf<TypePredicateFn<string[]>>();
});
});
44 changes: 37 additions & 7 deletions test/predicate/factory/shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ describe('shape()', () => {

type User = {
name: Name;
title: string;
job: {
title: string;
};
role: Role;
value: number;
age?: number;
Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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();
});
});