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
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,16 @@
"devDependencies": {
"@commitlint/config-conventional": "^19.8.1",
"@commitlint/types": "^19.8.1",
"@types/node": "^22.16.3",
"@types/node": "^22.16.5",
"@vitest/coverage-v8": "^3.2.4",
"@webdeveric/eslint-config-ts": "^0.11.0",
"@webdeveric/prettier-config": "^0.3.0",
"commitlint": "^19.8.1",
"commitlint-plugin-cspell": "^0.3.0",
"conventional-changelog-conventionalcommits": "^9.1.0",
"cspell": "^9.1.5",
"cspell": "^9.2.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.5",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"husky": "^9.1.7",
Expand Down
757 changes: 370 additions & 387 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions src/assertion/assertPathExists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { get } from '../get.js';
import { has } from '../has.js';

import type { TypePredicateFn } from '../types/functions.js';
import type { WithPath } from '../types/objects.js';

export function assertPathExists<Input extends object, InputPath extends string | number>(
input: Input,
path: InputPath,
): asserts input is Input & WithPath<Input, InputPath>;

export function assertPathExists<Input extends object, InputPath extends string | number, Value>(
input: Input,
path: InputPath,
predicate: TypePredicateFn<Value>,
): asserts input is Input & WithPath<Input, InputPath, Value>;

export function assertPathExists<Input extends object, InputPath extends string | number>(
input: Input,
path: InputPath,
predicate?: TypePredicateFn<unknown>,
): asserts input is Input & WithPath<Input, InputPath> {
if (!has(input, path)) {
throw new Error(`object path (${path}) does not exist on input`);
}

if (predicate && !predicate(get(input, path))) {
throw new Error(`object path (${path}) failed predicate check`);
}
}
1 change: 1 addition & 0 deletions src/assertion/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@ export * from './assertIsSymbol.js';
export * from './assertIsSymbolArray.js';
export * from './assertIsUndefined.js';
export * from './assertIsUndefinedArray.js';
export * from './assertPathExists.js';
export * from './assertPredicate.js';
4 changes: 2 additions & 2 deletions src/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ export function get<Input extends object, InputPath extends Path<Input>>(
path: InputPath,
): PathValue<Input, InputPath>;

export function get<Input extends object, InputPath extends string>(input: Input, path: InputPath): unknown;
export function get<Input extends object, InputPath extends PropertyKey>(input: Input, path: InputPath): unknown;

export function get<Input extends object, InputPath extends Path<Input> | string>(
export function get<Input extends object, InputPath extends Path<Input> | PropertyKey>(
input: Input,
path: InputPath,
): unknown {
Expand Down
49 changes: 49 additions & 0 deletions src/has.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { pathParts } from './pathParts.js';
import { isAnyObject } from './predicate/isAnyObject.js';

import type { FromPath, Merge, Path, PathValue } from './types/objects.js';
import type { IfNever, Pretty } from './types/utils.js';

/**
* Determine if the path exists on an object.
*/
// Known path so the `Input` type is unchanged
export function has<Input extends object, InputPath extends Path<Input>>(input: Input, path: InputPath): input is Input;

// Unknown path so the `Input` type is extended
export function has<Input extends object, InputPath extends string | number>(
input: Input,
path: InputPath,
): input is Pretty<Input & Merge<Input, FromPath<InputPath, unknown>>>;

// Unknown input can be assume to match at least the shape the input path makes
export function has<InputPath extends string | number>(
input: unknown,
path: InputPath,
): input is FromPath<InputPath, unknown>;

export function has<Input extends object, InputPath extends Path<Input> | string | number>(
input: Input,
path: InputPath,
): input is Input & FromPath<InputPath, IfNever<PathValue<Input, InputPath>, unknown, PathValue<Input, InputPath>>> {
if (!isAnyObject(input)) {
return false;
}

if (path === '') {
return false;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let current: any = input;

for (const part of pathParts(path)) {
if (typeof current === 'object' && current !== null && part in current) {
current = current[part];
} else {
return false;
}
}

return true;
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export * from './getPackageName.js';
export * from './getPaths.js';
export * from './getRandomItem.js';
export * from './getType.js';
export * from './has.js';
export * from './hasAdditionalProperties.js';
export * from './inRange.js';
export * from './isEmpty.js';
Expand All @@ -51,6 +52,7 @@ export * from './randomInt.js';
export * from './redactCredentialsInURL.js';
export * from './resultify.js';
export * from './secToString.js';
export * from './set.js';
export * from './sort-factory.js';
export * from './sort.js';
export * from './stripWhitespace.js';
Expand Down
58 changes: 58 additions & 0 deletions src/set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { pathParts } from './pathParts.js';

import type { Path, PathValue } from './types/objects.js';

/**
* Set the value of a property
*/
export function set<Input extends object, InputPath extends Path<Input>, Value extends PathValue<Input, InputPath>>(
input: Input,
path: InputPath,
value: Value,
): Value;

export function set<Input extends object, InputPath extends string, Value>(
input: Input,
path: InputPath,
value: Value,
): Value;

export function set<Input extends object, InputPath extends Path<Input> | string, Value>(
input: Input,
path: InputPath,
value: Value,
): Value {
if (path === '') {
throw new Error('Path cannot be an empty string');
}

if (typeof path === 'string' && /\b(__proto__|constructor|prototype)\b/.test(path)) {
throw new Error('Cannot pollute prototype');
}

const parts = Array.from(pathParts(path));

const lastPart = parts.pop();

if (typeof lastPart === 'undefined') {
throw new Error('Path must have at least one part');
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let current: any = input;

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`);
}
}

return (current[lastPart] = value);
}
58 changes: 55 additions & 3 deletions src/types/objects.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { IfArray, IfArrayLike } from './arrays.js';
import type { Primitive } from './common.js';
import type { CamelCase } from './strings.js';
import type { UnknownRecord } from './records.js';
import type { AutoCompletableString, CamelCase } from './strings.js';
import type { KeyValueTuple } from './tuples.js';
import type { CanBeUndefined, IfNever } from './utils.js';
import type { CanBeUndefined, IfNever, Pretty } from './utils.js';

export type Assign<Target, Source> = IfNever<Target, Source, Omit<Target, keyof (Target | Source)> & Source>;

Expand Down Expand Up @@ -119,7 +120,7 @@ export type GetValueForKey<
*/
export type PathValue<
Type extends object,
TargetPath extends Path<Type>,
TargetPath extends Path<Type> | AutoCompletableString | number,
Optional extends boolean = CanBeUndefined<Type, true, false>,
> = TargetPath extends `${infer Key}.${infer Rest}`
? GetValueForKey<Type, Key, Optional> extends infer Value
Expand All @@ -132,3 +133,54 @@ export type PathValue<
export type PathValues<Type extends object, TargetPaths extends Path<Type>> = {
[TargetPath in TargetPaths as CamelCase<`${TargetPath}`, '.'>]: PathValue<Type, TargetPath>;
};

export type FromPath<TargetPath extends PropertyKey, Value> = TargetPath extends `${infer Key}.${infer Rest}`
? { [K in Key]: FromPath<Rest, Value> }
: { [K in TargetPath]: Value };

/**
* Get a union of all intermediate paths, ending with `TargetPath`
*/
export type AllPaths<TargetPath extends PropertyKey> = TargetPath extends `${infer Head}.${infer Tail}`
? Head | `${Head}.${AllPaths<Tail>}`
: TargetPath;

export type WithPath<Input extends object, InputPath extends PropertyKey, Value = unknown> = Pretty<
Input & Merge<Input, FromPath<InputPath, Value>>
>;

export type Merge<Left, Right> = Left extends unknown[]
? Right extends Record<number, unknown>
? MergeArrayWithObject<Left, Right>
: Right
: Left extends object
? Right extends object
? MergeObjects<Left, Right>
: Right
: Right;

/**
* @internal
*/
export type MergeObjects<Left, Right> = {
[Key in keyof Left | keyof Right]: Key extends keyof Right
? Key extends keyof Left // Key exists in both Left and Right
? Merge<Left[Key], Right[Key]>
: Right[Key] // Key exists only in Right
: Key extends keyof Left
? Left[Key] // Key exists only in Left
: never;
};

/**
* @internal
*/
export type MergeArrayWithObject<Left extends unknown[], Right extends UnknownRecord> = Left & {
[Key in keyof Right]: Key extends keyof Left
? Merge<Left[Key & keyof Left], Right[Key]>
: Key extends `${infer Index extends number}`
? Index extends keyof Left
? Merge<Left[Index], Right[Key]>
: Right[Key]
: Right[Key];
};
2 changes: 2 additions & 0 deletions src/types/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,5 @@ export type Simplify<Type> = Type extends string
export type IfHasOnlyNumericKeys<Type, T, F> = `${Exclude<keyof Type, symbol>}` extends `${number}` ? T : F;

export type HasOnlyNumericKeys<Type> = IfHasOnlyNumericKeys<Type, true, false>;

export type ParseNumber<T extends string> = T extends `${infer N extends number}` ? N : never;
27 changes: 27 additions & 0 deletions test/assertion/assertPathExists.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';

import { assertPathExists } from '../../src/assertion/assertPathExists.js';
import { isNumber } from '../../src/predicate/isNumber.js';
import { isString } from '../../src/predicate/isString.js';

describe('assertPathExists()', () => {
it('Throws when path does not exist on object', () => {
expect(() => {
assertPathExists({ name: 'Test' }, 'name');
}).not.toThrowError();

expect(() => {
assertPathExists({ name: 'Test' }, 'age');
}).toThrowError();
});

it('Throws when path does not pass predicate validation', () => {
expect(() => {
assertPathExists({ name: 'Test' }, 'name', isString);
}).not.toThrowError();

expect(() => {
assertPathExists({ name: 'Test' }, 'name', isNumber);
}).toThrowError();
});
});
33 changes: 33 additions & 0 deletions test/has.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, it, expectTypeOf } from 'vitest';

import { has } from '../src/has.js';

describe('has()', () => {
describe('Is a type predicate', () => {
it('Handles unknown input', () => {
const input: unknown = {
firstName: 'Test',
};

if (has(input, 'firstName')) {
expectTypeOf<typeof input>().toEqualTypeOf<{ firstName: unknown }>();
}
});

it('Handles tuple input', () => {
const data = ['test'] as const satisfies string[];

if (has(data, 0)) {
expectTypeOf<typeof data>().toEqualTypeOf<['test']>();
}
});

it('Handles array input', () => {
const data: string[] = ['test'];

if (has(data, 0)) {
expectTypeOf<typeof data>().toEqualTypeOf<string[]>();
}
});
});
});
Loading