Skip to content

Commit 697f516

Browse files
authored
Merge pull request #200 from webdeveric/dev
Added `has()`, `set()`, and `assertPathExists()`
2 parents 7abf979 + 0aaf9b2 commit 697f516

16 files changed

Lines changed: 902 additions & 397 deletions

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,16 @@
8787
"devDependencies": {
8888
"@commitlint/config-conventional": "^19.8.1",
8989
"@commitlint/types": "^19.8.1",
90-
"@types/node": "^22.16.3",
90+
"@types/node": "^22.16.5",
9191
"@vitest/coverage-v8": "^3.2.4",
9292
"@webdeveric/eslint-config-ts": "^0.11.0",
9393
"@webdeveric/prettier-config": "^0.3.0",
9494
"commitlint": "^19.8.1",
9595
"commitlint-plugin-cspell": "^0.3.0",
9696
"conventional-changelog-conventionalcommits": "^9.1.0",
97-
"cspell": "^9.1.5",
97+
"cspell": "^9.2.0",
9898
"eslint": "^8.57.1",
99-
"eslint-config-prettier": "^10.1.5",
99+
"eslint-config-prettier": "^10.1.8",
100100
"eslint-import-resolver-typescript": "^4.4.4",
101101
"eslint-plugin-import": "^2.32.0",
102102
"husky": "^9.1.7",

pnpm-lock.yaml

Lines changed: 370 additions & 387 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/assertion/assertPathExists.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { get } from '../get.js';
2+
import { has } from '../has.js';
3+
4+
import type { TypePredicateFn } from '../types/functions.js';
5+
import type { WithPath } from '../types/objects.js';
6+
7+
export function assertPathExists<Input extends object, InputPath extends string | number>(
8+
input: Input,
9+
path: InputPath,
10+
): asserts input is Input & WithPath<Input, InputPath>;
11+
12+
export function assertPathExists<Input extends object, InputPath extends string | number, Value>(
13+
input: Input,
14+
path: InputPath,
15+
predicate: TypePredicateFn<Value>,
16+
): asserts input is Input & WithPath<Input, InputPath, Value>;
17+
18+
export function assertPathExists<Input extends object, InputPath extends string | number>(
19+
input: Input,
20+
path: InputPath,
21+
predicate?: TypePredicateFn<unknown>,
22+
): asserts input is Input & WithPath<Input, InputPath> {
23+
if (!has(input, path)) {
24+
throw new Error(`object path (${path}) does not exist on input`);
25+
}
26+
27+
if (predicate && !predicate(get(input, path))) {
28+
throw new Error(`object path (${path}) failed predicate check`);
29+
}
30+
}

src/assertion/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,5 @@ export * from './assertIsSymbol.js';
4343
export * from './assertIsSymbolArray.js';
4444
export * from './assertIsUndefined.js';
4545
export * from './assertIsUndefinedArray.js';
46+
export * from './assertPathExists.js';
4647
export * from './assertPredicate.js';

src/get.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ export function get<Input extends object, InputPath extends Path<Input>>(
99
path: InputPath,
1010
): PathValue<Input, InputPath>;
1111

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

14-
export function get<Input extends object, InputPath extends Path<Input> | string>(
14+
export function get<Input extends object, InputPath extends Path<Input> | PropertyKey>(
1515
input: Input,
1616
path: InputPath,
1717
): unknown {

src/has.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { pathParts } from './pathParts.js';
2+
import { isAnyObject } from './predicate/isAnyObject.js';
3+
4+
import type { FromPath, Merge, Path, PathValue } from './types/objects.js';
5+
import type { IfNever, Pretty } from './types/utils.js';
6+
7+
/**
8+
* Determine if the path exists on an object.
9+
*/
10+
// Known path so the `Input` type is unchanged
11+
export function has<Input extends object, InputPath extends Path<Input>>(input: Input, path: InputPath): input is Input;
12+
13+
// Unknown path so the `Input` type is extended
14+
export function has<Input extends object, InputPath extends string | number>(
15+
input: Input,
16+
path: InputPath,
17+
): input is Pretty<Input & Merge<Input, FromPath<InputPath, unknown>>>;
18+
19+
// Unknown input can be assume to match at least the shape the input path makes
20+
export function has<InputPath extends string | number>(
21+
input: unknown,
22+
path: InputPath,
23+
): input is FromPath<InputPath, unknown>;
24+
25+
export function has<Input extends object, InputPath extends Path<Input> | string | number>(
26+
input: Input,
27+
path: InputPath,
28+
): input is Input & FromPath<InputPath, IfNever<PathValue<Input, InputPath>, unknown, PathValue<Input, InputPath>>> {
29+
if (!isAnyObject(input)) {
30+
return false;
31+
}
32+
33+
if (path === '') {
34+
return false;
35+
}
36+
37+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
38+
let current: any = input;
39+
40+
for (const part of pathParts(path)) {
41+
if (typeof current === 'object' && current !== null && part in current) {
42+
current = current[part];
43+
} else {
44+
return false;
45+
}
46+
}
47+
48+
return true;
49+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export * from './getPackageName.js';
3636
export * from './getPaths.js';
3737
export * from './getRandomItem.js';
3838
export * from './getType.js';
39+
export * from './has.js';
3940
export * from './hasAdditionalProperties.js';
4041
export * from './inRange.js';
4142
export * from './isEmpty.js';
@@ -51,6 +52,7 @@ export * from './randomInt.js';
5152
export * from './redactCredentialsInURL.js';
5253
export * from './resultify.js';
5354
export * from './secToString.js';
55+
export * from './set.js';
5456
export * from './sort-factory.js';
5557
export * from './sort.js';
5658
export * from './stripWhitespace.js';

src/set.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { pathParts } from './pathParts.js';
2+
3+
import type { Path, PathValue } from './types/objects.js';
4+
5+
/**
6+
* Set the value of a property
7+
*/
8+
export function set<Input extends object, InputPath extends Path<Input>, Value extends PathValue<Input, InputPath>>(
9+
input: Input,
10+
path: InputPath,
11+
value: Value,
12+
): Value;
13+
14+
export function set<Input extends object, InputPath extends string, Value>(
15+
input: Input,
16+
path: InputPath,
17+
value: Value,
18+
): Value;
19+
20+
export function set<Input extends object, InputPath extends Path<Input> | string, Value>(
21+
input: Input,
22+
path: InputPath,
23+
value: Value,
24+
): Value {
25+
if (path === '') {
26+
throw new Error('Path cannot be an empty string');
27+
}
28+
29+
if (typeof path === 'string' && /\b(__proto__|constructor|prototype)\b/.test(path)) {
30+
throw new Error('Cannot pollute prototype');
31+
}
32+
33+
const parts = Array.from(pathParts(path));
34+
35+
const lastPart = parts.pop();
36+
37+
if (typeof lastPart === 'undefined') {
38+
throw new Error('Path must have at least one part');
39+
}
40+
41+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42+
let current: any = input;
43+
44+
const useParts: PropertyKey[] = [];
45+
46+
// get a reference to the last node that will be updated
47+
for (const part of parts) {
48+
useParts.push(part);
49+
50+
if (typeof current === 'object' && current !== null && part in current) {
51+
current = current[part];
52+
} else {
53+
throw new Error(`Path "${useParts.join('.')}" does not exist in the input object`);
54+
}
55+
}
56+
57+
return (current[lastPart] = value);
58+
}

src/types/objects.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { IfArray, IfArrayLike } from './arrays.js';
22
import type { Primitive } from './common.js';
3-
import type { CamelCase } from './strings.js';
3+
import type { UnknownRecord } from './records.js';
4+
import type { AutoCompletableString, CamelCase } from './strings.js';
45
import type { KeyValueTuple } from './tuples.js';
5-
import type { CanBeUndefined, IfNever } from './utils.js';
6+
import type { CanBeUndefined, IfNever, Pretty } from './utils.js';
67

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

@@ -119,7 +120,7 @@ export type GetValueForKey<
119120
*/
120121
export type PathValue<
121122
Type extends object,
122-
TargetPath extends Path<Type>,
123+
TargetPath extends Path<Type> | AutoCompletableString | number,
123124
Optional extends boolean = CanBeUndefined<Type, true, false>,
124125
> = TargetPath extends `${infer Key}.${infer Rest}`
125126
? GetValueForKey<Type, Key, Optional> extends infer Value
@@ -132,3 +133,54 @@ export type PathValue<
132133
export type PathValues<Type extends object, TargetPaths extends Path<Type>> = {
133134
[TargetPath in TargetPaths as CamelCase<`${TargetPath}`, '.'>]: PathValue<Type, TargetPath>;
134135
};
136+
137+
export type FromPath<TargetPath extends PropertyKey, Value> = TargetPath extends `${infer Key}.${infer Rest}`
138+
? { [K in Key]: FromPath<Rest, Value> }
139+
: { [K in TargetPath]: Value };
140+
141+
/**
142+
* Get a union of all intermediate paths, ending with `TargetPath`
143+
*/
144+
export type AllPaths<TargetPath extends PropertyKey> = TargetPath extends `${infer Head}.${infer Tail}`
145+
? Head | `${Head}.${AllPaths<Tail>}`
146+
: TargetPath;
147+
148+
export type WithPath<Input extends object, InputPath extends PropertyKey, Value = unknown> = Pretty<
149+
Input & Merge<Input, FromPath<InputPath, Value>>
150+
>;
151+
152+
export type Merge<Left, Right> = Left extends unknown[]
153+
? Right extends Record<number, unknown>
154+
? MergeArrayWithObject<Left, Right>
155+
: Right
156+
: Left extends object
157+
? Right extends object
158+
? MergeObjects<Left, Right>
159+
: Right
160+
: Right;
161+
162+
/**
163+
* @internal
164+
*/
165+
export type MergeObjects<Left, Right> = {
166+
[Key in keyof Left | keyof Right]: Key extends keyof Right
167+
? Key extends keyof Left // Key exists in both Left and Right
168+
? Merge<Left[Key], Right[Key]>
169+
: Right[Key] // Key exists only in Right
170+
: Key extends keyof Left
171+
? Left[Key] // Key exists only in Left
172+
: never;
173+
};
174+
175+
/**
176+
* @internal
177+
*/
178+
export type MergeArrayWithObject<Left extends unknown[], Right extends UnknownRecord> = Left & {
179+
[Key in keyof Right]: Key extends keyof Left
180+
? Merge<Left[Key & keyof Left], Right[Key]>
181+
: Key extends `${infer Index extends number}`
182+
? Index extends keyof Left
183+
? Merge<Left[Index], Right[Key]>
184+
: Right[Key]
185+
: Right[Key];
186+
};

src/types/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,5 @@ export type Simplify<Type> = Type extends string
156156
export type IfHasOnlyNumericKeys<Type, T, F> = `${Exclude<keyof Type, symbol>}` extends `${number}` ? T : F;
157157

158158
export type HasOnlyNumericKeys<Type> = IfHasOnlyNumericKeys<Type, true, false>;
159+
160+
export type ParseNumber<T extends string> = T extends `${infer N extends number}` ? N : never;

0 commit comments

Comments
 (0)