@williamthorsen/toolbelt.arrays 4.0.1 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 5.0.1 — 2026-08-16
6
+
7
+ ### Tests
8
+
9
+ - Drop expect-type in favor of expectTypeOf (#175)
10
+
11
+ Replaces all imports of `expectTypeOf` from `expect-type` with the same import from `vitest`. Previously there had been imports from both libraries. `expect-type` is removed as a dependency.
12
+
13
+ ## 5.0.0 — 2026-08-15
14
+
15
+ ### Features
16
+
17
+ - 🚨 **Breaking:** Fix unsound narrowing in `getAtIndexOrThrow` and rename it to `getItemAtIndexOrThrow` (#152)
18
+
19
+ Fixes an issue where `getAtIndexOrThrow` could falsely treat an `undefined` return value as satisfying a return type that excluded `undefined`. The function now throws a `RangeError` if the array holds no item at the index and a `TypeError` if the index is not a safe integer. `undefined` is a valid return value if the input array's type allows `undefined` elements. The function is renamed `getItemAtIndexOrThrow`.
20
+
21
+ Separately, `findOrThrow` now decides a match by its predicate rather than by the value it found, so an element is not treated as not found merely because it is falsy. Its return type is now `T` rather than `NonNullable<T>`.
22
+
23
+ Migration: Consumers of `@williamthorsen/toolbelt.arrays/candidate` import `getItemAtIndexOrThrow` in place of `getAtIndexOrThrow`.
24
+
25
+ - 🚨 **Breaking:** Rename `findOrThrow` to `findItemOrThrow` and document it (#153)
26
+
27
+ Renames `findOrThrow` to `findItemOrThrow` in `@williamthorsen/toolbelt.arrays` for consistency with repo naming conventions.
28
+
29
+ Migration: Consumers import `findItemOrThrow` from `@williamthorsen/toolbelt.arrays/candidate`. The signature and behavior are unchanged.
30
+
5
31
  ## 4.0.1 — 2026-08-13
6
32
 
7
33
  ### Tooling
package/README.md CHANGED
@@ -6,4 +6,92 @@ Array utilities.
6
6
 
7
7
  ## Installation
8
8
 
9
+ ```sh
10
+ pnpm add @williamthorsen/toolbelt.arrays
11
+ ```
12
+
9
13
  Requires Node.js 24 or later.
14
+
15
+ `findItemOrThrow` and `getItemAtIndexOrThrow` are candidate tier: imported from `@williamthorsen/toolbelt.arrays/candidate` rather than the package root, and subject to change.
16
+
17
+ ## `findItemOrThrow`
18
+
19
+ ```ts
20
+ findItemOrThrow<T>(
21
+ items: ReadonlyArray<T>,
22
+ predicate: (item: T, index: number, items: ReadonlyArray<T>) => boolean,
23
+ options?: { label?: string },
24
+ ): T;
25
+ ```
26
+
27
+ Returns the first item satisfying the predicate, or throws if no item does.
28
+
29
+ ```ts
30
+ import { findItemOrThrow } from '@williamthorsen/toolbelt.arrays/candidate';
31
+
32
+ const account = findItemOrThrow(accounts, (candidate) => candidate.isActive, { label: 'active account' });
33
+ ```
34
+
35
+ Its value is narrowing. `Array.prototype.find` returns `T | undefined`, so every call needs a check or an assertion before the value is usable. This collapses that to `T` or throws, so the call site needs neither.
36
+
37
+ The predicate alone decides the match. An item satisfying it is returned whatever its value: `0`, `''`, `false`, `null`, and even `undefined` all pass through. `Array.prototype.find` cannot express this, because the `undefined` it returns conflates a missing match with a found `undefined`.
38
+
39
+ The narrowing is therefore bounded by `T`. Searching a `ReadonlyArray<string | undefined>` yields `string | undefined`, since a match proves an item satisfied the predicate, not that the item is defined. Where the elements themselves are nullable and the result must not be, the caller narrows after the call as it would anywhere else.
40
+
41
+ ### When it throws
42
+
43
+ No matching item throws an `Error`:
44
+
45
+ ```
46
+ Could not find item.
47
+ ```
48
+
49
+ `label` replaces `item` in that message, so a caller names what it was looking for:
50
+
51
+ ```ts
52
+ findItemOrThrow(users, (user) => user.id === id, { label: `user ${id}` });
53
+ // throws Error("Could not find user 42.")
54
+ ```
55
+
56
+ ## `getItemAtIndexOrThrow`
57
+
58
+ ```ts
59
+ getItemAtIndexOrThrow<T>(array: ReadonlyArray<T>, index: number): T;
60
+ ```
61
+
62
+ Returns the item at the index, or throws if the array has no item there.
63
+
64
+ ```ts
65
+ import { getItemAtIndexOrThrow } from '@williamthorsen/toolbelt.arrays/candidate';
66
+
67
+ const fields = parseRow(line);
68
+ const label = getItemAtIndexOrThrow(fields, labelColumnIndex);
69
+ ```
70
+
71
+ Its value is narrowing. Under `noUncheckedIndexedAccess`, indexing an array yields `T | undefined`, so every read needs a check or an assertion before the value is usable. This collapses that to `T` or throws, so the call site needs neither.
72
+
73
+ The index must be non-negative. A negative index throws rather than resolving from the end as `Array.prototype.at` would: The function exists to make a violated index invariant loud, and silently reading from the end would turn an off-by-one into a wrong answer. A caller who wants the last item names it:
74
+
75
+ ```ts
76
+ getItemAtIndexOrThrow(letters, letters.length - 1);
77
+ ```
78
+
79
+ ### When it throws
80
+
81
+ An index naming no item throws a `RangeError`, whether it is negative, reaches past the end of the array, or lands on a hole in a sparse one:
82
+
83
+ ```
84
+ No item at index 4 of an array of length 4.
85
+ ```
86
+
87
+ The message names the length rather than claiming the index is out of bounds, so the reader can tell the cases apart: An in-range index in the message means the array is sparse.
88
+
89
+ A non-integer index throws a `TypeError`:
90
+
91
+ ```
92
+ Index must be a safe integer, but received 0.5.
93
+ ```
94
+
95
+ This covers `NaN` and `Infinity` as well. `Array.prototype.at` truncates a fractional index toward zero, so `at(0.5)` and `at(NaN)` both return the first item; here they fail instead, since neither is a plausible thing to have meant.
96
+
97
+ An item is returned whatever its value: `0`, `''`, `false`, `null`, and even `undefined` all pass through, because presence at the index is what is tested, not the value read from it. Only absence throws.
@@ -0,0 +1,5 @@
1
+ export declare function findItemOrThrow<T>(items: ReadonlyArray<T>, predicate: (item: T, index: number, items: ReadonlyArray<T>) => boolean, options?: Options): T;
2
+ interface Options {
3
+ label?: string | undefined;
4
+ }
5
+ export {};
@@ -0,0 +1,9 @@
1
+ export function findItemOrThrow(items, predicate, options = {}) {
2
+ const { label = 'item' } = options;
3
+ for (const [index, item] of items.entries()) {
4
+ if (predicate(item, index, items)) {
5
+ return item;
6
+ }
7
+ }
8
+ throw new Error(`Could not find ${label}.`);
9
+ }
@@ -1,4 +1,4 @@
1
- import { getAtIndexOrThrow } from "./getAtIndexOrThrow.js";
1
+ import { getItemAtIndexOrThrow } from "./getItemAtIndexOrThrow.js";
2
2
  export function findWeightedIndex(cumulativeWeights, targetWeight) {
3
3
  if (cumulativeWeights.length === 0) {
4
4
  return undefined;
@@ -7,7 +7,7 @@ export function findWeightedIndex(cumulativeWeights, targetWeight) {
7
7
  return undefined;
8
8
  }
9
9
  for (let i = 0; i < cumulativeWeights.length; i++) {
10
- if (targetWeight <= getAtIndexOrThrow(cumulativeWeights, i)) {
10
+ if (targetWeight <= getItemAtIndexOrThrow(cumulativeWeights, i)) {
11
11
  return i;
12
12
  }
13
13
  }
@@ -0,0 +1 @@
1
+ export declare function getItemAtIndexOrThrow<T>(array: ReadonlyArray<T>, index: number): T;
@@ -0,0 +1,9 @@
1
+ export function getItemAtIndexOrThrow(array, index) {
2
+ if (!Number.isSafeInteger(index)) {
3
+ throw new TypeError(`Index must be a safe integer, but received ${index}.`);
4
+ }
5
+ if (!Object.hasOwn(array, index)) {
6
+ throw new RangeError(`No item at index ${index} of an array of length ${array.length}.`);
7
+ }
8
+ return array[index];
9
+ }
@@ -1,8 +1,8 @@
1
1
  export { arraify } from './arraify.js';
2
2
  export { extractWeights } from './extractWeights.js';
3
- export { findOrThrow } from './findOrThrow.js';
3
+ export { findItemOrThrow } from './findItemOrThrow.js';
4
4
  export { findWeightedIndex } from './findWeightedIndex.js';
5
- export { getAtIndexOrThrow } from './getAtIndexOrThrow.js';
5
+ export { getItemAtIndexOrThrow } from './getItemAtIndexOrThrow.js';
6
6
  export { includes } from './includes.js';
7
7
  export { listDuplicateItems } from './listDuplicateItems.js';
8
8
  export { listUniqueItems } from './listUniqueItems.js';
@@ -1,8 +1,8 @@
1
1
  export { arraify } from "./arraify.js";
2
2
  export { extractWeights } from "./extractWeights.js";
3
- export { findOrThrow } from "./findOrThrow.js";
3
+ export { findItemOrThrow } from "./findItemOrThrow.js";
4
4
  export { findWeightedIndex } from "./findWeightedIndex.js";
5
- export { getAtIndexOrThrow } from "./getAtIndexOrThrow.js";
5
+ export { getItemAtIndexOrThrow } from "./getItemAtIndexOrThrow.js";
6
6
  export { includes } from "./includes.js";
7
7
  export { listDuplicateItems } from "./listDuplicateItems.js";
8
8
  export { listUniqueItems } from "./listUniqueItems.js";
@@ -1,8 +1,8 @@
1
1
  import { generateRandom } from '@williamthorsen/toolbelt.numbers/candidate';
2
- import { getAtIndexOrThrow } from "./getAtIndexOrThrow.js";
2
+ import { getItemAtIndexOrThrow } from "./getItemAtIndexOrThrow.js";
3
3
  export function pickItem(items, options = {}) {
4
4
  if (items.length === 0) {
5
5
  throw new Error('Cannot pick an item from an empty array.');
6
6
  }
7
- return getAtIndexOrThrow(items, Math.floor(generateRandom(options) * items.length));
7
+ return getItemAtIndexOrThrow(items, Math.floor(generateRandom(options) * items.length));
8
8
  }
@@ -1,10 +1,10 @@
1
1
  import { assert } from '@williamthorsen/toolbelt.guards';
2
2
  import { generateRandom } from '@williamthorsen/toolbelt.numbers/candidate';
3
3
  import { findWeightedIndex } from "./findWeightedIndex.js";
4
- import { getAtIndexOrThrow } from "./getAtIndexOrThrow.js";
4
+ import { getItemAtIndexOrThrow } from "./getItemAtIndexOrThrow.js";
5
5
  export function pickWeightedIndex(cumulativeWeights, options = {}) {
6
6
  assertValidCumulativeWeights(cumulativeWeights);
7
- const cumulativeWeight = getAtIndexOrThrow(cumulativeWeights, cumulativeWeights.length - 1);
7
+ const cumulativeWeight = getItemAtIndexOrThrow(cumulativeWeights, cumulativeWeights.length - 1);
8
8
  const randomValue = generateRandom(options);
9
9
  const targetWeight = randomValue * cumulativeWeight;
10
10
  const pickedIndex = findWeightedIndex(cumulativeWeights, targetWeight);
@@ -24,7 +24,7 @@ export function assertValidCumulativeWeights(weights, nItems = weights.length) {
24
24
  }
25
25
  function assertAscendingWeights(values) {
26
26
  for (let i = 1; i < values.length; i++) {
27
- if (getAtIndexOrThrow(values, i) < getAtIndexOrThrow(values, i - 1)) {
27
+ if (getItemAtIndexOrThrow(values, i) < getItemAtIndexOrThrow(values, i - 1)) {
28
28
  throw new Error('Cumulative weights must be in ascending order.');
29
29
  }
30
30
  }
@@ -1,4 +1,4 @@
1
- import { getAtIndexOrThrow } from "./getAtIndexOrThrow.js";
1
+ import { getItemAtIndexOrThrow } from "./getItemAtIndexOrThrow.js";
2
2
  import { assertValidCumulativeWeights, pickWeightedIndex } from "./pickWeightedIndex.js";
3
3
  import { toCumulativeValues } from "./toCumulativeValues.js";
4
4
  export function pickWeightedItem(items, weights) {
@@ -6,7 +6,7 @@ export function pickWeightedItem(items, weights) {
6
6
  assertValidCumulativeWeights(cumulativeWeights, items.length);
7
7
  return function pickItem(options = {}) {
8
8
  const index = pickWeightedIndex(cumulativeWeights, options);
9
- return getAtIndexOrThrow(items, index);
9
+ return getItemAtIndexOrThrow(items, index);
10
10
  };
11
11
  }
12
12
  export const toPickWeightedItem = pickWeightedItem;
@@ -1,5 +1,5 @@
1
1
  import { pickInteger, SeededRng } from '@williamthorsen/toolbelt.numbers/candidate';
2
- import { getAtIndexOrThrow } from "./getAtIndexOrThrow.js";
2
+ import { getItemAtIndexOrThrow } from "./getItemAtIndexOrThrow.js";
3
3
  export function shuffle(items, options = {}) {
4
4
  const shuffled = [...items];
5
5
  shuffleInPlace(shuffled, options);
@@ -9,8 +9,8 @@ export function shuffleInPlace(items, options = {}) {
9
9
  const seed = SeededRng.spawn(options.seed);
10
10
  for (let i = items.length - 1; i > 0; i--) {
11
11
  const j = pickInteger({ max: i, seed });
12
- const swapped = getAtIndexOrThrow(items, i);
13
- items[i] = getAtIndexOrThrow(items, j);
12
+ const swapped = getItemAtIndexOrThrow(items, i);
13
+ items[i] = getItemAtIndexOrThrow(items, j);
14
14
  items[j] = swapped;
15
15
  }
16
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/toolbelt.arrays",
3
- "version": "4.0.1",
3
+ "version": "5.0.1",
4
4
  "description": "Array utilities",
5
5
  "keywords": [
6
6
  "array",
@@ -44,7 +44,7 @@
44
44
  "CHANGELOG.md"
45
45
  ],
46
46
  "dependencies": {
47
- "@williamthorsen/toolbelt.guards": "3.1.10",
47
+ "@williamthorsen/toolbelt.guards": "3.1.11",
48
48
  "@williamthorsen/toolbelt.numbers": "6.0.1"
49
49
  },
50
50
  "engines": {
@@ -1,5 +0,0 @@
1
- export declare function findOrThrow<T>(items: ReadonlyArray<T>, predicate: (item: T, index: number, items: ReadonlyArray<T>) => boolean, options?: FindOrThrowOptions): NonNullable<T>;
2
- interface FindOrThrowOptions {
3
- label?: string | undefined;
4
- }
5
- export {};
@@ -1,8 +0,0 @@
1
- export function findOrThrow(items, predicate, options = {}) {
2
- const { label = 'item' } = options;
3
- const foundItem = items.find(predicate);
4
- if (!foundItem) {
5
- throw new Error(`Could not find ${label}.`);
6
- }
7
- return foundItem;
8
- }
@@ -1 +0,0 @@
1
- export declare function getAtIndexOrThrow<T>(array: ReadonlyArray<T>, index: number): T;
@@ -1,6 +0,0 @@
1
- export function getAtIndexOrThrow(array, index) {
2
- if (index < 0 || index >= array.length) {
3
- throw new RangeError(`Index ${index} is out of bounds.`);
4
- }
5
- return array[index];
6
- }