Skip to content

Commit dec1c7b

Browse files
authored
Merge pull request #198 from webdeveric/dev
Added `additionalProperties` support to `shape()`
2 parents 7e80692 + 474b902 commit dec1c7b

13 files changed

Lines changed: 166 additions & 29 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { bench } from 'vitest';
2+
3+
import { hasAdditionalProperties } from '../src/hasAdditionalProperties.js';
4+
5+
const input = {
6+
name: 'Test',
7+
age: 100_000,
8+
job: {
9+
title: 'Tester',
10+
},
11+
};
12+
13+
const input2 = {
14+
...input,
15+
extra: true,
16+
};
17+
18+
const knownProperties = ['name', 'age', 'job'];
19+
20+
bench('hasAdditionalProperties()', () => {
21+
hasAdditionalProperties(input, knownProperties);
22+
hasAdditionalProperties(input2, knownProperties);
23+
});

src/hasAdditionalProperties.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Determine if the `input` object has properties other than the provided `knownProperties`.
3+
*/
4+
export const hasAdditionalProperties = (input: object, knownProperties: PropertyKey[]): boolean => {
5+
const properties = Array.isArray(input) ? [...knownProperties.map(String), 'length'] : knownProperties;
6+
7+
return !Reflect.ownKeys(input).every((key) => properties.includes(key));
8+
};

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export * from './getPackageName.js';
3535
export * from './getPaths.js';
3636
export * from './getRandomItem.js';
3737
export * from './getType.js';
38+
export * from './hasAdditionalProperties.js';
3839
export * from './inRange.js';
3940
export * from './isEmpty.js';
4041
export * from './iterateForever.js';

src/predicate/assume.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* This function is used to create a type guard that always returns true, effectively allowing
3+
* the input to be treated as the specified type without any runtime checks.
4+
*
5+
* @privateRemarks
6+
* The `is` prefix is omitted because there is not any conditional logic in the function.
7+
* It is more of a command to `tsc` to assume the type rather than a check.
8+
*/
9+
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
10+
export const assume = <Type>(_input: unknown, ..._args: any[]): _input is Type => true;

src/predicate/factory/is.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { TypePredicateFn } from '../../types/functions.js';
2-
import type { Pretty, Writable } from '../../types/utils.js';
32

3+
/**
4+
* Use `Object.is()` to compare `input` against allowed values.
5+
*/
46
export const is =
5-
<const T extends unknown[]>(...options: T): TypePredicateFn<Pretty<Writable<T[number]>>> =>
7+
<const T extends unknown[]>(...values: T): TypePredicateFn<T[number]> =>
68
(input: unknown): input is T[number] =>
7-
options.some((option) => Object.is(input, option));
9+
values.some((value) => Object.is(input, value));

src/predicate/factory/shape.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable @typescript-eslint/ban-types */
2+
import { hasAdditionalProperties } from '../../hasAdditionalProperties.js';
23
import { isAnyObject } from '../isAnyObject.js';
34

45
import { literal } from './literal.js';
@@ -40,24 +41,26 @@ export type InferTypeFromShape<Shape extends ObjectShapeRecord<object>> = {
4041

4142
export const shape = <Type extends object, Shape extends ObjectShapeRecord<Type> = ObjectShapeRecord<Type>>(
4243
objectShape: Shape,
44+
additionalProperties = true,
4345
): TypePredicateFn<Pretty<Type & InferTypeFromShape<Shape>>> => {
44-
const entries: [key: string | symbol, predicate: TypePredicateFn<unknown>][] = Reflect.ownKeys(objectShape).map(
45-
(key) => {
46-
const value = Reflect.get(objectShape, key);
47-
48-
const predicate =
49-
typeof value === 'function'
50-
? (value as TypePredicateFn<unknown>)
51-
: value instanceof RegExp
52-
? matching(value)
53-
: isAnyObject(value)
54-
? shape(value)
55-
: literal(value);
56-
57-
return [key, predicate];
58-
},
59-
);
46+
const knownProperties = Reflect.ownKeys(objectShape);
47+
const entries: [key: string | symbol, predicate: TypePredicateFn<unknown>][] = knownProperties.map((key) => {
48+
const value = Reflect.get(objectShape, key);
49+
50+
const predicate =
51+
typeof value === 'function'
52+
? (value as TypePredicateFn<unknown>)
53+
: value instanceof RegExp
54+
? matching(value)
55+
: isAnyObject(value)
56+
? shape(value)
57+
: literal(value);
58+
59+
return [key, predicate];
60+
});
6061

6162
return (input: unknown): input is Pretty<Type & InferTypeFromShape<Shape>> =>
62-
isAnyObject(input) && entries.every(([key, predicate]) => predicate(Reflect.get(input, key)));
63+
isAnyObject(input) &&
64+
entries.every(([key, predicate]) => predicate(Reflect.get(input, key))) &&
65+
(additionalProperties || !hasAdditionalProperties(input, knownProperties));
6366
};

src/predicate/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './assume.js';
12
export * from './isAny.js';
23
export * from './isAnyObject.js';
34
export * from './isAnyObjectWith.js';

src/predicate/isAny.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1+
import { assume } from './assume.js';
2+
13
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
export const isAny = (_input: unknown): _input is any => true;
4+
export const isAny = assume<any>;

src/predicate/isUnknown.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
export const isUnknown = (_input: unknown): _input is unknown => true;
1+
import { assume } from './assume.js';
2+
3+
export const isUnknown = assume<unknown>;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { hasAdditionalProperties } from '../src/hasAdditionalProperties.js';
4+
5+
describe('hasAdditionalProperties()', () => {
6+
it('returns true when additional properties are detected', () => {
7+
expect(hasAdditionalProperties({ a: 1, b: 2 }, ['a'])).toBeTruthy();
8+
});
9+
10+
it('returns false when additional properties are not detected', () => {
11+
expect(hasAdditionalProperties({ a: 1, [Symbol.for('b')]: 2 }, ['a', Symbol.for('b')])).toBeFalsy();
12+
});
13+
14+
it('automatically allows `length` when the input is an array', () => {
15+
expect(hasAdditionalProperties([0, 1], [0])).toBeTruthy();
16+
expect(hasAdditionalProperties([0, 1], [0, 'length'])).toBeTruthy();
17+
18+
expect(hasAdditionalProperties([0, 1], [0, 1])).toBeFalsy();
19+
expect(hasAdditionalProperties([0, 1], [0, 1, 'length'])).toBeFalsy();
20+
});
21+
});

0 commit comments

Comments
 (0)