Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"url": "https://github.com/webdeveric/utils/issues"
},
"homepage": "https://github.com/webdeveric/utils/#readme",
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184",
"packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad",
"scripts": {
"clean": "rimraf ./dist/",
"prebuild": "pnpm clean",
Expand All @@ -87,14 +87,14 @@
"devDependencies": {
"@commitlint/config-conventional": "^19.8.1",
"@commitlint/types": "^19.8.1",
"@types/node": "^22.16.0",
"@types/node": "^22.16.3",
"@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.2.0",
"conventional-changelog-conventionalcommits": "^9.0.0",
"cspell": "^9.1.2",
"commitlint-plugin-cspell": "^0.3.0",
"conventional-changelog-conventionalcommits": "^9.1.0",
"cspell": "^9.1.3",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.4.4",
Expand All @@ -104,9 +104,9 @@
"lint-staged": "^16.1.2",
"prettier": "^3.6.2",
"rimraf": "^6.0.1",
"semantic-release": "^24.2.6",
"semantic-release": "^24.2.7",
"typescript": "^5.8.3",
"validate-package-exports": "^0.11.0",
"validate-package-exports": "^0.12.0",
"vitest": "^3.2.4"
},
"pnpm": {
Expand Down
1,533 changes: 775 additions & 758 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

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

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

export function get<Input extends object, const InputPath extends Path<Input>>(
input: Input,
path: InputPath,
): PathValue<Input, InputPath>;

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

export function get<const Input extends object, const InputPath extends Path<Input> | string>(
input: Input,
path: InputPath,
): unknown {
if (path === '') {
return input;
}

// 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;
}
}

return current;
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export * from './delayAnimationFrame.js';
export * from './delayAnimationFrames.js';
export * from './describeInput.js';
export * from './escapeRegExp.js';
export * from './get.js';
export * from './getDateString.js';
export * from './getISODateString.js';
export * from './getMaxValue.js';
Expand All @@ -44,6 +45,7 @@ export * from './jsonParse.js';
export * from './looksLikeURL.js';
export * from './normalize.js';
export * from './parseNumber.js';
export * from './pathParts.js';
export * from './prefix.js';
export * from './randomInt.js';
export * from './redactCredentialsInURL.js';
Expand Down
15 changes: 15 additions & 0 deletions src/pathParts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function* pathParts(input: PropertyKey): Generator<PropertyKey> {
if (typeof input !== 'string') {
yield input;

return;
}

const regexp = /\w+/g;

const iterator = input.matchAll(regexp);

for (const part of iterator) {
yield part[0];
}
}
12 changes: 11 additions & 1 deletion src/types/arrays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ export type IfLength<A extends any[], L extends number, T, F> = GetLength<A> ext

export type IfEmpty<A extends any[], T, F> = GetLength<A> extends 0 ? T : F;

export type IfArray<Type, T, F> = Type extends unknown[] ? T : F;
/**
* Check if `Type` is an array (excluding tuples).
*/
export type IfArray<Type, T, F> = Type extends readonly unknown[] ? (number extends Type['length'] ? T : F) : F;

export type IsArray<Type> = IfArray<Type, true, false>;

export type NonEmptyArray<Type> = [Type, ...Type[]];

// Arrays and tuples both extends readonly arrays, so we can use the same type guard.
export type IfArrayLike<Type, T, F> = Type extends readonly unknown[] ? T : F;

export type IsArrayLike<Type> = IfArrayLike<Type, true, false>;
49 changes: 33 additions & 16 deletions src/types/objects.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IfArray, IfArrayLike } from './arrays.js';
import type { Primitive } from './common.js';
import type { CamelCase } from './strings.js';
import type { KeyValueTuple } from './tuples.js';
Expand Down Expand Up @@ -29,13 +30,13 @@ export type MethodNames<Type, Key extends keyof Type = keyof Type> = Key extends

/**
* Get keys of `Type` for use in `Path`.
* Keys should not be method names or symbols.
* Keys should not be symbols.
*
* @internal
*/
export type ValidKeys<Type> = Type extends unknown[]
? number
: `${Exclude<keyof NonNullable<Type>, symbol | MethodNames<NonNullable<Type>>>}`;
export type ValidKeys<Type extends object> = object extends Type
? never
: Exclude<keyof Type, IfArrayLike<Type, symbol | MethodNames<Type>, symbol>>;

/**
* Get the possible dot notations for a given `Type`.
Expand All @@ -55,7 +56,7 @@ export type ValidKeys<Type> = Type extends unknown[]
* type ExampleNotation = "job" | "job.title";
* ```
*/
export type PathDotNotation<Type, Key extends keyof Type = keyof Type> =
export type PathDotNotation<Type extends object, Key extends keyof Type = keyof Type> =
Key extends ValidKeys<Type>
? Type[Key] extends Primitive
? Key
Expand All @@ -78,7 +79,27 @@ export type PathDotNotation<Type, Key extends keyof Type = keyof Type> =
* type ExampleNotation = "job" | "job.title";
* ```
*/
export type Path<Type> = PathDotNotation<NonNullableProperties<Type>, keyof Type> | ValidKeys<Type>;
export type Path<Type extends object> = PathDotNotation<NonNullableProperties<Type>, keyof Type> | ValidKeys<Type>;

/**
* @internal
*/
export type MaybeOptional<Type, Optional extends boolean> = Optional extends true ? Type | undefined : Type;

/**
* @internal
*/
export type GetValueForKey<
Type extends object,
Key extends PropertyKey,
Optional extends boolean = CanBeUndefined<Type, true, false>,
> = Key extends keyof Type
? MaybeOptional<Type[Key], IfArray<Type, true, Optional>> // array items should always be checked for undefined
: Key extends `${infer Index extends number}`
? Index extends keyof Type
? MaybeOptional<Type[Index], IfArray<Type, true, Optional>>
: never
: never;

/**
* Get the type for a given dot notation path.
Expand All @@ -97,21 +118,17 @@ export type Path<Type> = PathDotNotation<NonNullableProperties<Type>, keyof Type
* ```
*/
export type PathValue<
Type,
Type extends object,
TargetPath extends Path<Type>,
Optional extends boolean = CanBeUndefined<Type, true, false>,
> = TargetPath extends `${infer Key}.${infer Rest}`
? Key extends keyof Type
? Rest extends Path<NonNullable<Type[Key]>>
? PathValue<NonNullable<Type[Key]>, Rest, CanBeUndefined<Type[Key], true, false>>
? GetValueForKey<Type, Key, Optional> extends infer Value
? Rest extends Path<NonNullable<Value>>
? PathValue<NonNullable<Value>, Rest, CanBeUndefined<Value, true, false>>
: never
: never
: TargetPath extends keyof Type
? Optional extends true
? Type[TargetPath] | undefined
: Type[TargetPath]
: never;
: GetValueForKey<Type, TargetPath, Optional>;

export type PathValues<Type, TargetPaths extends Path<Type>> = {
export type PathValues<Type extends object, TargetPaths extends Path<Type>> = {
[TargetPath in TargetPaths as CamelCase<`${TargetPath}`, '.'>]: PathValue<Type, TargetPath>;
};
5 changes: 5 additions & 0 deletions src/types/tuples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ export type NumberRangeTuple = RangeTuple<number>;
export type KeyValueTuple<K extends PropertyKey = PropertyKey, V = unknown> = [key: K, value: V];

export type TupleToArray<T extends any[]> = T[number][];

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type IfTuple<Type, T, F> = Type extends readonly [...infer _] ? (number extends Type['length'] ? F : T) : F;

export type IsTuple<Type> = IfTuple<Type, true, false>;
54 changes: 54 additions & 0 deletions test/get.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';

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

describe('get()', () => {
const input = {
age: 100_000,
name: {
first: 'Test',
last: 'Testerson',
},
sayHi(): void {
console.log('Hi!');
},
role: [
{
job: {
title: 'Developer',
},
},
],
};

it('returns a value from an object', () => {
const lastName = get(input, 'name.last');
const jobTitle = get(input, 'role.0.job.title');

expect(lastName).toBe(input.name.last);
expect(jobTitle).toBe(input.role[0]?.job.title);
});

it('Returns undefined when path is invalid', () => {
const result = get(input, 'role.0.job.nonExistent');

expect(result).toBeUndefined();
});

it('Handles built in objects', () => {
const date = new Date();

const result = get(date, 'toString');

// eslint-disable-next-line @typescript-eslint/unbound-method
expect(result).toBe(date.toString);
});

it('Empty path returns input', () => {
const date = new Date();

const result = get(date, '');

expect(result).toBe(date);
});
});
26 changes: 26 additions & 0 deletions test/pathParts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';

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

describe('pathParts()', () => {
it.each([
['role.0.job.title', ['role', '0', 'job', 'title']],
['role.0.job.title', ['role', '0', 'job', 'title']],
['role.0.job["title"]', ['role', '0', 'job', 'title']],
['role.0.job[title]', ['role', '0', 'job', 'title']],
['role["0"].job.title', ['role', '0', 'job', 'title']],
['role["0"].job.title', ['role', '0', 'job', 'title']],
['role["0"].job["title"]', ['role', '0', 'job', 'title']],
['role["0"].job[title]', ['role', '0', 'job', 'title']],
['role[0].job.title', ['role', '0', 'job', 'title']],
['role[0].job.title', ['role', '0', 'job', 'title']],
['role[0].job["title"]', ['role', '0', 'job', 'title']],
['role[0].job[title]', ['role', '0', 'job', 'title']],
])('returns an iterator of path parts: %s => %j', (input, expected) => {
expect(Array.from(pathParts(input))).toEqual(expected);
});

it('returns input for non-string input', () => {
expect(Array.from(pathParts(123))).toEqual([123]);
});
});
2 changes: 1 addition & 1 deletion test/types/arrays.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('IfEmpty', () => {

describe('IfArray', () => {
it('returns the true type if the type is an array', () => {
expectTypeOf<IfArray<[], 'yes', 'no'>>().toEqualTypeOf<'yes'>();
expectTypeOf<IfArray<string[], 'yes', 'no'>>().toEqualTypeOf<'yes'>();
});

it('returns the false type if the type is not an array', () => {
Expand Down
Loading