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
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.5",
"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,543 changes: 780 additions & 763 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

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

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

export function get<Input extends object>(input: Input, path: ''): Input;

export function get<Input extends object, InputPath extends Path<Input>>(
input: 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 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;
}
8 changes: 5 additions & 3 deletions src/getRandomItem.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { randomInt } from './randomInt.js';

export function getRandomItem(input: []): undefined;
import type { NonEmptyArray } from './types/arrays.js';

export function getRandomItem<T>(input: [T, ...T[]]): T;
export function getRandomItem<Type>(input: NonEmptyArray<Type>): Type;

export function getRandomItem<T>(input: T[]): T | undefined {
export function getRandomItem<Type>(input: Type[]): Type | undefined;

export function getRandomItem<Type>(input: Type[]): Type | undefined {
switch (input.length) {
case 0:
return;
Expand Down
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
19 changes: 19 additions & 0 deletions src/pathParts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function* pathParts(input: PropertyKey): Generator<PropertyKey> {
if (input === '') {
return;
}

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>;
9 changes: 9 additions & 0 deletions src/types/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export type IfUnknown<Type, T, F> = unknown extends Type ? T : F;

export type IfPromise<Type, T, F> = Type extends Promise<any> ? T : F;

export type IfSame<Left, Right, T, F> = [Left] extends [Right] ? ([Right] extends [Left] ? T : F) : F;

export type NotPromise<Type> = Type extends Promise<any> ? never : Type;

export type CanBeUndefined<Type, T, F> = Type | undefined extends Type ? T : F;
Expand Down Expand Up @@ -147,3 +149,10 @@ export type Simplify<Type> = Type extends string
[Key in keyof Type]: Simplify<Type[Key]>;
}
: Type;

/**
* Check if the keys of a type are only numeric.
*/
export type IfHasOnlyNumericKeys<Type, T, F> = `${Exclude<keyof Type, symbol>}` extends `${number}` ? T : F;

export type HasOnlyNumericKeys<Type> = IfHasOnlyNumericKeys<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);
});
});
37 changes: 37 additions & 0 deletions test/getRandomItem.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expectTypeOf, it } from 'vitest';

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

describe('getRandomItem()', () => {
it('Returns a random item from an array', () => {
const data: number[] = [1, 2, 3];

const randomItem = getRandomItem(data);

expectTypeOf<typeof randomItem>().toEqualTypeOf<number | undefined>();
});

it('Returns a random item from a const array (tuple)', () => {
const data = [1, 2, 3] as const satisfies number[];

const randomItem = getRandomItem(data);

expectTypeOf<typeof randomItem>().toEqualTypeOf<1 | 2 | 3>();
});

it('Returns `undefined` from an empty array', () => {
const data: never[] = [];

const randomItem = getRandomItem(data);

expectTypeOf<typeof randomItem>().toEqualTypeOf<undefined>();
});

it('Returns `T | undefined` from an untyped array', () => {
const data = ['a', 'b', 'c', 1, 2, 3];

const randomItem = getRandomItem(data);

expectTypeOf<typeof randomItem>().toEqualTypeOf<string | number | undefined>();
});
});
30 changes: 30 additions & 0 deletions test/pathParts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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]);
});

it('returns empty array for empty string input', () => {
expect(Array.from(pathParts(''))).toEqual([]);
});
});
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