(
input: T,
- properties: P[],
+ properties: P | P[],
): input is T & Record => {
- return isAnyObject(input) && properties.every((property) => property in input);
+ return (
+ isAnyObject(input) &&
+ (Array.isArray(properties) ? properties.every((property) => property in input) : properties in input)
+ );
};
diff --git a/src/set.ts b/src/set.ts
index 322ac85..71f0da8 100644
--- a/src/set.ts
+++ b/src/set.ts
@@ -1,3 +1,5 @@
+import { assertIsObject } from './assertion/assertIsObject.js';
+import { getNode } from './internal/getNode.js';
import { pathParts } from './pathParts.js';
import type { Path, PathValue } from './types/objects.js';
@@ -48,21 +50,13 @@ export function set | string
throw new Error('Path must have at least one part');
}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let current: any = input;
+ const result = getNode(input, parts);
- const useParts: PropertyKey[] = [];
-
- // get a reference to the last node that will be updated
- for (const part of parts) {
- useParts.push(part);
-
- if (typeof current === 'object' && current !== null && part in current) {
- current = current[part];
- } else {
- throw new Error(`Path "${useParts.join('.')}" does not exist in the input object`);
- }
+ if (!result.found) {
+ throw new Error(`Path "${result.visitedNodes.join('.')}" does not exist in the input object`);
}
- return (current[lastPart] = value);
+ assertIsObject(result.value);
+
+ return (result.value[lastPart] = value);
}
diff --git a/test/internal/getNode.test.ts b/test/internal/getNode.test.ts
new file mode 100644
index 0000000..48b2b71
--- /dev/null
+++ b/test/internal/getNode.test.ts
@@ -0,0 +1,210 @@
+import { describe, it, expect } from 'vitest';
+
+import { getNode } from '../../src/internal/getNode.js';
+
+describe('getNode()', () => {
+ it('Gets a node from an object', () => {
+ const data = {
+ job: { title: 'tester' },
+ };
+
+ expect(getNode(data, ['job', 'title'])).toEqual({
+ value: 'tester',
+ found: true,
+ visitedNodes: ['job', 'title'],
+ });
+ });
+
+ it('Returns the input when no node names are provided', () => {
+ const data = { name: 'test' };
+
+ const result = getNode(data, []);
+
+ expect(result).toEqual({
+ value: data,
+ found: true,
+ visitedNodes: [],
+ });
+
+ expect(result.value).toBe(data);
+ });
+
+ it('Returns a reference to nested objects', () => {
+ const job = { title: 'tester' };
+ const data = { job };
+
+ expect(getNode(data, ['job']).value).toBe(job);
+ });
+
+ it('Gets nodes from arrays', () => {
+ const data = {
+ items: [{ id: 1 }, { id: 2 }],
+ };
+
+ expect(getNode(data, ['items', 1, 'id'])).toEqual({
+ value: 2,
+ found: true,
+ visitedNodes: ['items', 1, 'id'],
+ });
+
+ expect(getNode(data, ['items', '0', 'id'])).toEqual({
+ value: 1,
+ found: true,
+ visitedNodes: ['items', '0', 'id'],
+ });
+ });
+
+ it('Gets nodes using symbol keys', () => {
+ const key = Symbol('key');
+ const data = {
+ [key]: { value: true },
+ };
+
+ expect(getNode(data, [key, 'value'])).toEqual({
+ value: true,
+ found: true,
+ visitedNodes: [key, 'value'],
+ });
+ });
+
+ it('Finds nodes whose value is undefined or null', () => {
+ const data = {
+ a: undefined,
+ b: null,
+ };
+
+ expect(getNode(data, ['a'])).toEqual({
+ value: undefined,
+ found: true,
+ visitedNodes: ['a'],
+ });
+
+ expect(getNode(data, ['b'])).toEqual({
+ value: null,
+ found: true,
+ visitedNodes: ['b'],
+ });
+ });
+
+ it('Includes inherited properties', () => {
+ const data = Object.create({ inherited: 'value' });
+
+ expect(getNode(data, ['inherited'])).toEqual({
+ value: 'value',
+ found: true,
+ visitedNodes: ['inherited'],
+ });
+ });
+
+ it('Accepts any iterable of node names', () => {
+ const data = {
+ a: { b: { c: 'c' } },
+ };
+
+ function* names(): Generator {
+ yield 'a';
+ yield 'b';
+ yield 'c';
+ }
+
+ expect(getNode(data, names())).toEqual({
+ value: 'c',
+ found: true,
+ visitedNodes: ['a', 'b', 'c'],
+ });
+
+ expect(getNode(data, new Set(['a', 'b']))).toEqual({
+ value: { c: 'c' },
+ found: true,
+ visitedNodes: ['a', 'b'],
+ });
+ });
+
+ describe('Not found', () => {
+ it('Returns not found when a property does not exist', () => {
+ const data = {
+ job: { title: 'tester' },
+ };
+
+ expect(getNode(data, ['job', 'salary'])).toEqual({
+ value: undefined,
+ found: false,
+ visitedNodes: ['job', 'salary'],
+ });
+ });
+
+ it('Stops at the first missing node', () => {
+ const data = {
+ job: { title: 'tester' },
+ };
+
+ expect(getNode(data, ['company', 'name', 'length'])).toEqual({
+ value: undefined,
+ found: false,
+ visitedNodes: ['company'],
+ });
+ });
+
+ it('Returns not found when traversing through null or undefined', () => {
+ const data = {
+ a: null,
+ b: undefined,
+ };
+
+ expect(getNode(data, ['a', 'b'])).toEqual({
+ value: undefined,
+ found: false,
+ visitedNodes: ['a', 'b'],
+ });
+
+ expect(getNode(data, ['b', 'c'])).toEqual({
+ value: undefined,
+ found: false,
+ visitedNodes: ['b', 'c'],
+ });
+ });
+
+ it('Does not traverse into primitive values', () => {
+ const data = {
+ name: 'test',
+ count: 1,
+ };
+
+ expect(getNode(data, ['name', 'length'])).toEqual({
+ value: undefined,
+ found: false,
+ visitedNodes: ['name', 'length'],
+ });
+
+ expect(getNode(data, ['count', 'toFixed'])).toEqual({
+ value: undefined,
+ found: false,
+ visitedNodes: ['count', 'toFixed'],
+ });
+ });
+
+ it('Does not traverse into functions', () => {
+ const data = {
+ fn: Object.assign(() => {}, { prop: true }),
+ };
+
+ expect(getNode(data, ['fn', 'prop'])).toEqual({
+ value: undefined,
+ found: false,
+ visitedNodes: ['fn', 'prop'],
+ });
+ });
+
+ it('Returns not found for out of bounds array indexes', () => {
+ const data = {
+ items: ['a'],
+ };
+
+ expect(getNode(data, ['items', 1])).toEqual({
+ value: undefined,
+ found: false,
+ visitedNodes: ['items', 1],
+ });
+ });
+ });
+});
diff --git a/test/predicate/factory/record.test-d.ts b/test/predicate/factory/record.test-d.ts
new file mode 100644
index 0000000..e20105f
--- /dev/null
+++ b/test/predicate/factory/record.test-d.ts
@@ -0,0 +1,33 @@
+import { describe, expectTypeOf, it } from 'vitest';
+
+import { literal } from '../../../src/predicate/factory/literal.js';
+import { record } from '../../../src/predicate/factory/record.js';
+import { isNumber } from '../../../src/predicate/isNumber.js';
+import { isString } from '../../../src/predicate/isString.js';
+
+import type { TypePredicateFn } from '../../../src/types/functions.js';
+
+describe('record()', () => {
+ it('Returns a type predicate function', () => {
+ const fn = record(isString, isNumber);
+
+ expectTypeOf(fn).toBeFunction();
+ expectTypeOf(fn).parameter(0).toEqualTypeOf();
+ expectTypeOf(fn).toEqualTypeOf>>();
+ });
+
+ it('Narrows input', () => {
+ const input: unknown = { a: 1 };
+
+ if (record(literal('a'), isNumber)(input)) {
+ expectTypeOf(input).toEqualTypeOf>();
+ }
+ });
+
+ it('Only accepts string or symbol key predicates', () => {
+ expectTypeOf(record).parameter(0).toEqualTypeOf>();
+
+ // @ts-expect-error: number keys are not supported since object keys are strings or symbols.
+ record(isNumber, isNumber);
+ });
+});
diff --git a/test/predicate/factory/record.test.ts b/test/predicate/factory/record.test.ts
new file mode 100644
index 0000000..127e087
--- /dev/null
+++ b/test/predicate/factory/record.test.ts
@@ -0,0 +1,77 @@
+import { describe, it, expect, vi } from 'vitest';
+
+import { literal } from '../../../src/predicate/factory/literal.js';
+import { record } from '../../../src/predicate/factory/record.js';
+import { isNumber } from '../../../src/predicate/isNumber.js';
+import { isString } from '../../../src/predicate/isString.js';
+import { isSymbol } from '../../../src/predicate/isSymbol.js';
+
+describe('record()', () => {
+ it('Returns a type predicate function', () => {
+ expect(record(isString, isNumber)).toBeInstanceOf(Function);
+ });
+
+ it('Returns true when all keys and values pass', () => {
+ const fn = record(isString, isNumber);
+
+ expect(fn({})).toBeTruthy();
+ expect(fn({ a: 1, b: 2 })).toBeTruthy();
+ expect(fn(Object.create(null))).toBeTruthy();
+ });
+
+ it('Returns false when a value fails', () => {
+ const fn = record(isString, isNumber);
+
+ expect(fn({ a: 1, b: 'test' })).toBeFalsy();
+ });
+
+ it('Returns false when a key fails', () => {
+ const fn = record(isString, isNumber);
+
+ expect(fn({ a: 1, [Symbol('test')]: 2 })).toBeFalsy();
+ expect(record(isSymbol, isNumber)({ [Symbol('test')]: 1 })).toBeTruthy();
+ expect(record(literal('a'), isNumber)({ a: 1, b: 2 })).toBeFalsy();
+ });
+
+ it('Treats numeric keys as strings', () => {
+ expect(record(isString, isNumber)({ 1: 1 })).toBeTruthy();
+ // @ts-expect-error: number keys are not supported since object keys are strings or symbols.
+ expect(record(isNumber, isNumber)({ 1: 1 })).toBeFalsy();
+ });
+
+ it('Checks non-enumerable own keys', () => {
+ const input = Object.defineProperty({ a: 1 }, 'hidden', { value: 'test', enumerable: false });
+
+ expect(record(isString, isNumber)(input)).toBeFalsy();
+ });
+
+ it('Ignores inherited keys', () => {
+ const input = Object.create({ inherited: 'test' });
+
+ input.a = 1;
+
+ expect(record(isString, isNumber)(input)).toBeTruthy();
+ });
+
+ it('Returns false for non-objects', () => {
+ const fn = record(isString, isNumber);
+
+ expect(fn(null)).toBeFalsy();
+ expect(fn(undefined)).toBeFalsy();
+ expect(fn([1, 2, 3])).toBeFalsy();
+ expect(fn('test')).toBeFalsy();
+ expect(fn(123)).toBeFalsy();
+ expect(fn(() => 1)).toBeFalsy();
+ });
+
+ it('Stops checking after the first failure', () => {
+ const keyPredicate = vi.fn(isString) as unknown as typeof isString;
+ const valuePredicate = vi.fn(isNumber) as unknown as typeof isNumber;
+
+ const fn = record(keyPredicate, valuePredicate);
+
+ expect(fn({ a: 'fail', b: 2, c: 3 })).toBeFalsy();
+ expect(keyPredicate).toHaveBeenCalledTimes(1);
+ expect(valuePredicate).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/test/predicate/isAnyObjectWith.test.ts b/test/predicate/isAnyObjectWith.test.ts
index dd61605..464f0cb 100644
--- a/test/predicate/isAnyObjectWith.test.ts
+++ b/test/predicate/isAnyObjectWith.test.ts
@@ -9,7 +9,41 @@ describe('isAnyObjectWith()', () => {
expect(isAnyObjectWith(['item'], ['length'])).toBeTruthy();
});
- it.each([null, false, 'string', Math.PI, Symbol()])('Returns false for invalid inputs', (item) => {
- expect(isAnyObjectWith(item, ['property'])).toBeFalsy();
+ it('Accepts a single property', () => {
+ expect(isAnyObjectWith({ a: 1 }, 'a')).toBeTruthy();
+ expect(isAnyObjectWith({ a: 1 }, 'b')).toBeFalsy();
+ expect(isAnyObjectWith(['a'], 0)).toBeTruthy();
+ expect(isAnyObjectWith(['a'], 1)).toBeFalsy();
+
+ const symbol = Symbol();
+
+ expect(isAnyObjectWith({ [symbol]: true }, symbol)).toBeTruthy();
+ expect(isAnyObjectWith({}, symbol)).toBeFalsy();
+ });
+
+ it('Requires every property when given an array', () => {
+ expect(isAnyObjectWith({ a: 1, b: 2 }, ['a', 'b'])).toBeTruthy();
+ expect(isAnyObjectWith({ a: 1 }, ['a', 'b'])).toBeFalsy();
+ });
+
+ it('Returns true for an empty array of properties', () => {
+ expect(isAnyObjectWith({}, [])).toBeTruthy();
});
+
+ it('Includes inherited properties', () => {
+ expect(isAnyObjectWith({}, 'toString')).toBeTruthy();
+ expect(isAnyObjectWith(Object.setPrototypeOf({}, { property: true }), 'property')).toBeTruthy();
+ });
+
+ it('Includes properties with undefined values', () => {
+ expect(isAnyObjectWith({ property: undefined }, 'property')).toBeTruthy();
+ });
+
+ it.each([null, undefined, false, 'string', Math.PI, Symbol(), () => {}])(
+ 'Returns false for invalid inputs',
+ (item) => {
+ expect(isAnyObjectWith(item, ['property'])).toBeFalsy();
+ expect(isAnyObjectWith(item, 'property')).toBeFalsy();
+ },
+ );
});