Repository: remeda/remeda (ts)

Issue:
`chunk` can cause nodejs to throw memory exception
In `chunk<T[]>(array: T[], size: number): Chunked<T[]>`, if `size = 0`, then nodejs will crash with a very cryptic error:
```
#
# Fatal error in , line 0
# Fatal JavaScript invalid size error 169220804 (see crbug.com/1201626)
#
#
#
#FailureMessage Object: 0x16bbd90f8
```

This was extremely hard to debug. A precondition check that throws a normal JS `Error` would be much appreciated.

Pull request:
feat(chunk): literal chunk sizes
1. Fixes #784
2. Adds typing for literal chunk sizes.
3. Slight optimizations to the runtime to handle cases where the chunk size is larger than the array, when it is 1, and to use sparse arrays to handle very large arrays more efficiently. 

Patch:
diff --git a/src/chunk.ts b/src/chunk.ts
index 17feb1e..816fed6 100644
--- a/src/chunk.ts
+++ b/src/chunk.ts
@@ -1,13 +1,204 @@
-import type { IterableContainer, NonEmptyArray } from "./internal/types";
+import {
+  type IfNever,
+  type IntRange,
+  type IsNumericLiteral,
+  type LessThan,
+  type Subtract,
+  type ValueOf,
+} from "type-fest";
+import type {
+  IterableContainer,
+  NonEmptyArray,
+  NTuple,
+  TupleParts,
+} from "./internal/types";
 import { purry } from "./purry";
 
-type Chunked<T extends IterableContainer> = T[number] extends never
+// This prevents typescript from failing on complex arrays and large chunks. It
+// allows the typing to remain useful even when very large chunks are needed,
+// without loosing fidelity on smaller ones. It was chosen by trial-and-error,
+// and given some more wiggle room because the complexity of the array also
+// plays a role in when typescript fails to recurse.
+// See the type tests for an example.
+type MAX_LITERAL_SIZE = 350;
+
+type Chunk<
+  T extends IterableContainer,
+  N extends number,
+> = T extends readonly []
   ? []
-  : T extends
-        | readonly [...Array<unknown>, unknown]
-        | readonly [unknown, ...Array<unknown>]
-    ? NonEmptyArray<NonEmptyArray<T[number]>>
-    : Array<NonEmptyArray<T[number]>>;
+  : IsNumericLiteral<N> extends true
+    ? LessThan<N, 1> extends true
+      ? never
+      : LessThan<N, MAX_LITERAL_SIZE> extends true
+        ? // The spread here is used as a form of "Simplify" for arrays; without
+          // it our return type isn't useful.
+          [...LiteralChunk<T, N>]
+        : GenericChunk<T>
+    : GenericChunk<T>;
+
+type LiteralChunk<
+  T,
+  N extends number,
+  // TupleParts allows us to optimize the algorithm by taking all items up to
+  // the rest param, all items after the rest param, and the type of the rest
+  // param, in a single recursive scan, so that all other parts of our algorithm
+  // don't need to recurse again in order to handle them.
+  Parts extends TupleParts<T> = TupleParts<T>,
+> =
+  | ChunkInfinite<
+      // Our result will always have the prefix tuple chunked the same way, so
+      // we compute it once here and send it to the main logic below
+      ChunkFinite<Parts["prefix"], N>,
+      Parts["item"],
+      Parts["suffix"],
+      N
+    >
+  // If both the prefix and suffix tuples are empty then our input is a simple
+  // array of the form `Array<Item>`. This means it could also be empty, so we
+  // need to add the empty output to our return type.
+  | ([...Parts["prefix"], ...Parts["suffix"]]["length"] extends 0 ? [] : never);
+
+/**
+ * This type **only** works if the input array `T` is a finite tuple (
+ * `[number, "abc", true, { a: "hello" }]` etc..., and it doesn't contain a rest
+ * parameter). For these inputs the chunked output could be computed as literal
+ * finite tuples too.
+ */
+type ChunkFinite<
+  T,
+  N extends number,
+  // Important! Result is initialized with an empty array (and not `[[]]`)
+  // because the result of `chunk` on an empty array is `[]` and not `[[]]`.
+  Result = [],
+> = T extends readonly [infer Head, ...infer Rest]
+  ? // We continue consuming the input tuple recursively item by item.
+    ChunkFinite<
+      Rest,
+      N,
+      Result extends [
+        ...infer Previous extends Array<Array<unknown>>,
+        infer Current extends Array<unknown>,
+      ]
+        ? // We take a look at the last chunk in the result, this is the
+          // "current" chunk where new items would be added, all chunks before
+          // it are already full.
+          Current["length"] extends N
+          ? // The current chunk is full, create a new chunk and put Head in it.
+            [...Previous, Current, [Head]]
+          : // The current chunk is not full yet, so we add Head to it.
+            [...Previous, [...Current, Head]]
+        : // This would only happen on the first iteration, when result is
+          // still empty. In this case we create the first chunk and put Head
+          // in it.
+          [[Head]]
+    >
+  : // We know T is a finite tuple, so the only case where we would reach this
+    // is when T is empty, and in that case our results array contains the whole
+    // input chunked by N.
+    Result;
+
+/**
+ * Here lies the main complexity of building the chunk type. It takes the prefix
+ * chunks, the rest param item type, and the suffix (not chunked!) and it
+ * creates all possible combinations of adding items to the prefix and suffix
+ * for all possible scenarios for how many items the rest param "represents".
+ */
+type ChunkInfinite<
+  PrefixChunks,
+  Item,
+  Suffix extends Array<unknown>,
+  N extends number,
+> = IfNever<
+  Item,
+  // The rest param is never when there is no rest param, the whole array is
+  // a finite tuple and is represented already by the prefix chunks. Suffix is
+  // assumed to be empty in this case.
+  PrefixChunks,
+  PrefixChunks extends [
+    ...infer PrefixFullChunks extends Array<Array<unknown>>,
+    infer LastPrefixChunk extends Array<unknown>,
+  ]
+    ? // When our prefix chunks are not empty it means we need to look at all
+      // combinations of mixing the prefix, the suffix, and different counts of
+      // the rest param until we cover all possible scenarios.
+      | ValueOf<{
+            // We want to iterate over all possible padding sizes we can add to
+            // the last prefix chunk until we reach N
+            // (`0..N-LastPrefixChunk.length`). We need to do this because until
+            // the last prefix chunk is full, we need to consider the suffix
+            // being part of it too...
+            [Padding in IntRangeInclusive<
+              0,
+              Subtract<N, LastPrefixChunk["length"]>
+            >]: [
+              ...PrefixFullChunks,
+              ...ChunkFinite<
+                // Create a new array that would **not** contain a rest param
+                // (so it's finite) made of the last prefix chunk, padding from
+                // the rest param, and the suffix.
+                [...LastPrefixChunk, ...NTuple<Item, Padding>, ...Suffix],
+                N
+              >,
+            ];
+          }>
+        // Additionally, we need to consider the case where the last prefix
+        // chunk **is** full, and follow it with an array of chunks of the rest
+        // param (and only them), and then followed by all possible variations
+        // of the suffix chunks.
+        | [
+            ...PrefixFullChunks,
+            [
+              // Fully padded last prefix chunk
+              ...LastPrefixChunk,
+              ...NTuple<Item, Subtract<N, LastPrefixChunk["length"]>>,
+            ],
+            ...Array<NTuple<Item, N>>,
+            ...SuffixChunk<Suffix, Item, N>,
+          ]
+    : // When our prefix chunks are empty we only need to handle the suffix
+      [...Array<NTuple<Item, N>>, ...SuffixChunk<Suffix, Item, N>]
+>;
+
+/**
+ * This type assumes it takes a finite tuple that represents the suffix of our
+ * input array. It builds all possible combinations of adding items to the
+ * **head** of the suffix in order to pad the suffix until the last chunk is
+ * full.
+ */
+type SuffixChunk<
+  T extends Array<unknown>,
+  Item,
+  N extends number,
+> = T extends readonly []
+  ? // If we don't have a suffix we simply create a single chunk with all
+    // possible non-empty sub-arrays of `Item` up to size `N`.
+    [ValueOf<{ [K in IntRangeInclusive<1, N>]: NTuple<Item, K> }>]
+  : ValueOf<{
+      // When suffix isn't empty we pad the head of the suffix and compute it's
+      // chunks for all possible padding sizes.
+      [Padding in IntRange<0, N>]: ChunkFinite<
+        [...NTuple<Item, Padding>, ...T],
+        N
+      >;
+    }>;
+
+/**
+ * This is the legacy type used when we don't know what N is. We can only adjust
+ * our output based on if we know for sure that the array is empty or not.
+ */
+type GenericChunk<T extends IterableContainer> = T extends
+  | readonly [...Array<unknown>, unknown]
+  | readonly [unknown, ...Array<unknown>]
+  ? NonEmptyArray<NonEmptyArray<T[number]>>
+  : Array<NonEmptyArray<T[number]>>;
+
+/**
+ * Type-fest's IntRange doesn't include the `To` value, but we need it!
+ */
+type IntRangeInclusive<From extends number, To extends number> =
+  | IntRange<From, To>
+  | To;
 
 /**
  * Split an array into groups the length of `size`. If `array` can't be split evenly, the final chunk will be the remaining elements.
@@ -22,10 +213,10 @@ type Chunked<T extends IterableContainer> = T[number] extends never
  * @dataFirst
  * @category Array
  */
-export function chunk<T extends IterableContainer>(
+export function chunk<T extends IterableContainer, N extends number>(
   array: T,
-  size: number,
-): Chunked<T>;
+  size: N,
+): Chunk<T, N>;
 
 /**
  * Split an array into groups the length of `size`. If `array` can't be split evenly, the final chunk will be the remaining elements.
@@ -39,21 +230,49 @@ export function chunk<T extends IterableContainer>(
  * @dataLast
  * @category Array
  */
-export function chunk<T extends IterableContainer>(
-  size: number,
-): (array: T) => Chunked<T>;
+export function chunk<N extends number>(
+  size: N,
+): <T extends IterableContainer>(array: T) => Chunk<T, N>;
 
 export function chunk(...args: ReadonlyArray<unknown>): unknown {
   return purry(chunkImplementation, args);
 }
 
 function chunkImplementation<T>(
-  array: ReadonlyArray<T>,
+  data: ReadonlyArray<T>,
   size: number,
 ): Array<Array<T>> {
-  const ret: Array<Array<T>> = [];
-  for (let offset = 0; offset < array.length; offset += size) {
-    ret.push(array.slice(offset, offset + size));
+  if (size < 1) {
+    throw new RangeError(
+      `chunk: A chunk size of '${size}' would result in an infinite array`,
+    );
+  }
+
+  if (data.length === 0) {
+    return [];
   }
-  return ret;
+
+  if (size >= data.length) {
+    // Optimized for when there is only one chunk.
+    return [[...data]];
+  }
+
+  const chunks = Math.ceil(data.length / size);
+
+  // eslint-disable-next-line unicorn/no-new-array -- This is OK, a sparse array allows us to handle very large arrays more efficiently.
+  const result = new Array<Array<T>>(chunks);
+
+  if (size === 1) {
+    // Optimized for when we don't need slice.
+    for (const [index, item] of data.entries()) {
+      result[index] = [item];
+    }
+  } else {
+    for (let index = 0; index < chunks; index += 1) {
+      const start = index * size;
+      result[index] = data.slice(start, start + size);
+    }
+  }
+
+  return result;
 }
diff --git a/src/internal/types.ts b/src/internal/types.ts
index d64966d..b8d8e7e 100644
--- a/src/internal/types.ts
+++ b/src/internal/types.ts
@@ -234,3 +234,43 @@ export type GuardType<T, Fallback = never> = T extends (
 ) => x is infer U
   ? U
   : Fallback;
+
+/**
+ * An array with *exactly* N elements in it.
+ *
+ * Only literal N values are supported. For very large N the type might result
+ * in a recurse depth error. For negative N the type would result in an infinite
+ * recursion. None of these have protections because this is an internal type!
+ */
+export type NTuple<
+  T,
+  N extends number,
+  Result extends Array<unknown> = [],
+> = Result["length"] extends N ? Result : NTuple<T, N, [...Result, T]>;
+
+/**
+ * Takes an array and returns the types that make up it's parts. The suffix is
+ * anything before the rest parameter (if any), the prefix is anything after the
+ * rest parameter (if any), and the item is the type of the rest parameter.
+ *
+ * The output could be used to reconstruct the input: `[
+ *   ...TupleParts<T>["prefix"],
+ *   ...Array<TupleParts<T>["item"]>,
+ *   ...TupleParts<T>["suffix"],
+ * ]`.
+ */
+export type TupleParts<
+  T,
+  Prefix extends Array<unknown> = [],
+  Suffix extends Array<unknown> = [],
+> = T extends readonly [infer Head, ...infer Tail]
+  ? TupleParts<Tail, [...Prefix, Head], Suffix>
+  : T extends readonly [...infer Head, infer Tail]
+    ? TupleParts<Head, Prefix, [Tail, ...Suffix]>
+    : T extends ReadonlyArray<infer Item>
+      ? {
+          prefix: Prefix;
+          item: Item;
+          suffix: Suffix;
+        }
+      : never;


Test patch:
diff --git a/src/chunk.test-d.ts b/src/chunk.test-d.ts
index 8408444..122de6f 100644
--- a/src/chunk.test-d.ts
+++ b/src/chunk.test-d.ts
@@ -1,74 +1,410 @@
 import { chunk } from "./chunk";
 import { type NonEmptyArray } from "./internal/types";
 
-test("empty tuple", () => {
-  const input: [] = [];
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<[]>();
-});
+describe("edge-cases", () => {
+  test("0 chunk size", () => {
+    const result = chunk([1, 2, 3], 0);
+    expectTypeOf(result).toEqualTypeOf<never>();
+  });
 
-test("readonly empty tuple", () => {
-  const input = [] as const;
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<[]>();
+  test("negative chunk size", () => {
+    const result = chunk([1, 2, 3], -10);
+    expectTypeOf(result).toEqualTypeOf<never>();
+  });
 });
 
-test("array", () => {
-  const input: Array<number> = [];
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<Array<NonEmptyArray<number>>>();
-});
+describe("regular (non-literal) size", () => {
+  describe("mutable", () => {
+    test("empty tuple", () => {
+      const result = chunk([] as [], 2 as number);
+      expectTypeOf(result).toEqualTypeOf<[]>();
+    });
 
-test("readonly array", () => {
-  const input: ReadonlyArray<number> = [];
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<Array<NonEmptyArray<number>>>();
-});
+    test("array", () => {
+      const result = chunk([] as Array<number>, 2 as number);
+      expectTypeOf(result).toEqualTypeOf<Array<NonEmptyArray<number>>>();
+    });
 
-test("tuple", () => {
-  const input: [number, number, number] = [123, 456, 789];
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<NonEmptyArray<NonEmptyArray<number>>>();
-});
+    test("tuple", () => {
+      const result = chunk(
+        [123, 456, 789] as [number, number, number],
+        2 as number,
+      );
+      expectTypeOf(result).toEqualTypeOf<
+        NonEmptyArray<NonEmptyArray<number>>
+      >();
+    });
 
-test("readonly tuple", () => {
-  const input: readonly [number, number, number] = [123, 456, 789];
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<NonEmptyArray<NonEmptyArray<number>>>();
-});
+    test("tuple with rest tail", () => {
+      const result = chunk(
+        [123, 456] as [number, ...Array<number>],
+        2 as number,
+      );
+      expectTypeOf(result).toEqualTypeOf<
+        NonEmptyArray<NonEmptyArray<number>>
+      >();
+    });
 
-test("tuple with rest tail", () => {
-  const input: [number, ...Array<number>] = [123, 456];
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<NonEmptyArray<NonEmptyArray<number>>>();
-});
+    test("tuple with rest middle", () => {
+      const result = chunk(
+        [123, 456] as [number, ...Array<number>, number],
+        2 as number,
+      );
+      expectTypeOf(result).toEqualTypeOf<
+        NonEmptyArray<NonEmptyArray<number>>
+      >();
+    });
 
-test("readonly tuple with rest tail", () => {
-  const input: readonly [number, ...Array<number>] = [123, 456];
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<NonEmptyArray<NonEmptyArray<number>>>();
-});
+    test("tuple with rest head", () => {
+      const result = chunk(
+        [123, 456] as [...Array<number>, number],
+        2 as number,
+      );
+      expectTypeOf(result).toEqualTypeOf<
+        NonEmptyArray<NonEmptyArray<number>>
+      >();
+    });
+  });
 
-test("tuple with rest middle", () => {
-  const input: [number, ...Array<number>, number] = [123, 456];
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<NonEmptyArray<NonEmptyArray<number>>>();
-});
+  describe("readonly", () => {
+    test("empty tuple", () => {
+      const result = chunk([] as const, 2 as number);
+      expectTypeOf(result).toEqualTypeOf<[]>();
+    });
 
-test("readonly tuple with rest middle", () => {
-  const input: readonly [number, ...Array<number>, number] = [123, 456];
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<NonEmptyArray<NonEmptyArray<number>>>();
-});
+    test("array", () => {
+      const result = chunk([] as ReadonlyArray<number>, 2 as number);
+      expectTypeOf(result).toEqualTypeOf<Array<NonEmptyArray<number>>>();
+    });
 
-test("tuple with rest head", () => {
-  const input: [...Array<number>, number] = [123, 456];
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<NonEmptyArray<NonEmptyArray<number>>>();
+    test("tuple", () => {
+      const result = chunk(
+        [123, 456, 789] as readonly [number, number, number],
+        2 as number,
+      );
+      expectTypeOf(result).toEqualTypeOf<
+        NonEmptyArray<NonEmptyArray<number>>
+      >();
+    });
+
+    test("tuple with rest tail", () => {
+      const result = chunk(
+        [123, 456] as readonly [number, ...Array<number>],
+        2 as number,
+      );
+      expectTypeOf(result).toEqualTypeOf<
+        NonEmptyArray<NonEmptyArray<number>>
+      >();
+    });
+
+    test("tuple with rest middle", () => {
+      const result = chunk(
+        [123, 456] as readonly [number, ...Array<number>, number],
+        2 as number,
+      );
+      expectTypeOf(result).toEqualTypeOf<
+        NonEmptyArray<NonEmptyArray<number>>
+      >();
+    });
+
+    test("tuple with rest head", () => {
+      const result = chunk(
+        [123, 456] as readonly [...Array<number>, number],
+        2 as number,
+      );
+      expectTypeOf(result).toEqualTypeOf<
+        NonEmptyArray<NonEmptyArray<number>>
+      >();
+    });
+  });
 });
 
-test("readonly tuple with rest head", () => {
-  const input: readonly [...Array<number>, number] = [123, 456];
-  const result = chunk(input, 2);
-  expectTypeOf(result).toEqualTypeOf<NonEmptyArray<NonEmptyArray<number>>>();
+describe("literal size", () => {
+  describe("mutable", () => {
+    test("empty tuple", () => {
+      const result = chunk([] as [], 2);
+      expectTypeOf(result).toEqualTypeOf<[]>();
+    });
+
+    test("array", () => {
+      const result = chunk([] as Array<number>, 2);
+      expectTypeOf(result).toEqualTypeOf<
+        [...Array<[number, number]>, [number, number] | [number]] | []
+      >();
+    });
+
+    test("tuple", () => {
+      const result = chunk([123, 456, 789] as [number, number, number], 2);
+      expectTypeOf(result).toEqualTypeOf<[[number, number], [number]]>();
+    });
+
+    describe("infinite tuples (rest/spread item)", () => {
+      describe("prefix arrays", () => {
+        test("prefix is shorter than chunk size", () => {
+          const result = chunk([1] as [number, ...Array<boolean>], 2);
+          expectTypeOf(result).toEqualTypeOf<
+            | [
+                [number, boolean],
+                ...Array<[boolean, boolean]>,
+                [boolean, boolean] | [boolean],
+              ]
+            // TODO: Ideally the following two lines should be folded into a
+            // single line: `[[number] | [number, boolean]]`
+            | [[number, boolean]]
+            | [[number]]
+          >();
+        });
+
+        test("prefix is same size as chunk size", () => {
+          const result = chunk(
+            [1, 2] as [number, number, ...Array<boolean>],
+            2,
+          );
+          expectTypeOf(result).toEqualTypeOf<
+            | [
+                [number, number],
+                ...Array<[boolean, boolean]>,
+                [boolean, boolean] | [boolean],
+              ]
+            | [[number, number]]
+          >();
+        });
+
+        test("prefix is longer than chunk size", () => {
+          const result = chunk(
+            [1, 2, 3] as [number, number, number, ...Array<boolean>],
+            2,
+          );
+          expectTypeOf(result).toEqualTypeOf<
+            | [
+                [number, number],
+                [number, boolean],
+                ...Array<[boolean, boolean]>,
+                [boolean, boolean] | [boolean],
+              ]
+            // TODO: Ideally the following two lines should be folded into a
+            // single line: `[[number, number], [number] | [number, boolean]]`
+            | [[number, number], [number, boolean]]
+            | [[number, number], [number]]
+          >();
+        });
+      });
+
+      describe("suffix arrays", () => {
+        test("suffix is shorter than chunk size", () => {
+          const result = chunk([1] as [...Array<boolean>, number], 2);
+          expectTypeOf(result).toEqualTypeOf<
+            | [...Array<[boolean, boolean]>, [boolean, number]]
+            | [...Array<[boolean, boolean]>, [number]]
+          >();
+        });
+
+        test("suffix is same size as chunk size", () => {
+          const result = chunk(
+            [1, 2] as [...Array<boolean>, number, number],
+            2,
+          );
+          expectTypeOf(result).toEqualTypeOf<
+            | [...Array<[boolean, boolean]>, [boolean, number], [number]]
+            | [...Array<[boolean, boolean]>, [number, number]]
+          >();
+        });
+
+        test("suffix is larger than chunk size", () => {
+          const result = chunk(
+            [1, 2, 3] as [...Array<boolean>, number, number, number],
+            2,
+          );
+          expectTypeOf(result).toEqualTypeOf<
+            | [
+                ...Array<[boolean, boolean]>,
+                [boolean, number],
+                [number, number],
+              ]
+            | [...Array<[boolean, boolean]>, [number, number], [number]]
+          >();
+        });
+      });
+
+      test("both prefix and suffix", () => {
+        const result = chunk(
+          [123, "abc"] as [number, ...Array<boolean>, string],
+          2,
+        );
+        expectTypeOf(result).toEqualTypeOf<
+          | [[number, boolean], ...Array<[boolean, boolean]>, [boolean, string]]
+          | [[number, boolean], ...Array<[boolean, boolean]>, [string]]
+          // TODO: This is the same type as the previous line (for an empty
+          // array), but it's hard to build the types to be aware of it because
+          // they come from different branches of the type.
+          | [[number, boolean], [string]]
+          | [[number, string]]
+        >();
+      });
+    });
+  });
+
+  describe("readonly", () => {
+    test("empty tuple", () => {
+      const result = chunk([] as const, 2);
+      expectTypeOf(result).toEqualTypeOf<[]>();
+    });
+
+    test("array", () => {
+      const result = chunk([] as ReadonlyArray<number>, 2);
+      expectTypeOf(result).toEqualTypeOf<
+        [...Array<[number, number]>, [number, number] | [number]] | []
+      >();
+    });
+
+    test("tuple", () => {
+      const result = chunk(
+        [123, 456, 789] as readonly [number, number, number],
+        2,
+      );
+      expectTypeOf(result).toEqualTypeOf<[[number, number], [number]]>();
+    });
+
+    test("const", () => {
+      const result = chunk([123, 456, 789] as const, 2);
+      expectTypeOf(result).toEqualTypeOf<[[123, 456], [789]]>();
+    });
+
+    describe("infinite tuples (rest/spread item)", () => {
+      describe("prefix arrays", () => {
+        test("prefix is shorter than chunk size", () => {
+          const result = chunk([1] as readonly [number, ...Array<boolean>], 2);
+          expectTypeOf(result).toEqualTypeOf<
+            | [
+                [number, boolean],
+                ...Array<[boolean, boolean]>,
+                [boolean, boolean] | [boolean],
+              ]
+            // TODO: Ideally the following two lines should be folded into a
+            // single line: `[[number] | [number, boolean]]`
+            | [[number, boolean]]
+            | [[number]]
+          >();
+        });
+
+        test("prefix is same size as chunk size", () => {
+          const result = chunk(
+            [1, 2] as readonly [number, number, ...Array<boolean>],
+            2,
+          );
+          expectTypeOf(result).toEqualTypeOf<
+            | [
+                [number, number],
+                ...Array<[boolean, boolean]>,
+                [boolean, boolean] | [boolean],
+              ]
+            | [[number, number]]
+          >();
+        });
+
+        test("prefix is longer than chunk size", () => {
+          const result = chunk(
+            [1, 2, 3] as readonly [number, number, number, ...Array<boolean>],
+            2,
+          );
+          expectTypeOf(result).toEqualTypeOf<
+            | [
+                [number, number],
+                [number, boolean],
+                ...Array<[boolean, boolean]>,
+                [boolean, boolean] | [boolean],
+              ]
+            // TODO: Ideally the following two lines should be folded into a
+            // single line: `[[number, number], [number] | [number, boolean]]`
+            | [[number, number], [number, boolean]]
+            | [[number, number], [number]]
+          >();
+        });
+      });
+
+      describe("suffix arrays", () => {
+        test("suffix is shorter than chunk size", () => {
+          const result = chunk([1] as readonly [...Array<boolean>, number], 2);
+          expectTypeOf(result).toEqualTypeOf<
+            | [...Array<[boolean, boolean]>, [boolean, number]]
+            | [...Array<[boolean, boolean]>, [number]]
+          >();
+        });
+
+        test("suffix is same size as chunk size", () => {
+          const result = chunk(
+            [1, 2] as readonly [...Array<boolean>, number, number],
+            2,
+          );
+          expectTypeOf(result).toEqualTypeOf<
+            | [...Array<[boolean, boolean]>, [boolean, number], [number]]
+            | [...Array<[boolean, boolean]>, [number, number]]
+          >();
+        });
+
+        test("suffix is larger than chunk size", () => {
+          const result = chunk(
+            [1, 2, 3] as readonly [...Array<boolean>, number, number, number],
+            2,
+          );
+          expectTypeOf(result).toEqualTypeOf<
+            | [
+                ...Array<[boolean, boolean]>,
+                [boolean, number],
+                [number, number],
+              ]
+            | [...Array<[boolean, boolean]>, [number, number], [number]]
+          >();
+        });
+      });
+
+      test("both prefix and suffix", () => {
+        const result = chunk(
+          [123, "abc"] as readonly [number, ...Array<boolean>, string],
+          2,
+        );
+        expectTypeOf(result).toEqualTypeOf<
+          | [[number, boolean], ...Array<[boolean, boolean]>, [boolean, string]]
+          | [[number, boolean], ...Array<[boolean, boolean]>, [string]]
+          // TODO: This is the same type as the previous line (for an empty
+          // array), but it's hard to build the types to be aware of it because
+          // they come from different branches of the type.
+          | [[number, boolean], [string]]
+          | [[number, string]]
+        >();
+      });
+    });
+  });
+
+  describe("max literal chunk size", () => {
+    test("below max", () => {
+      const [[firstItem, secondItem, ...otherItems]] = chunk(
+        ["abc", true] as [string, ...Array<number>, boolean],
+        200,
+      );
+      expectTypeOf(firstItem).toEqualTypeOf<string>();
+      expectTypeOf(secondItem).toEqualTypeOf<boolean | number>();
+      expectTypeOf(otherItems[197]).toEqualTypeOf<
+        boolean | number | undefined
+      >();
+      // @ts-expect-error [ts2339] - This item doesn't exist!
+      expectTypeOf(otherItems[198]).toEqualTypeOf<undefined>();
+    });
+  });
+
+  test("above max", () => {
+    const result = chunk(
+      ["abc", true] as [string, ...Array<number>, boolean],
+      1000,
+    );
+    expectTypeOf(result).toEqualTypeOf<
+      // These are simple non-empty arrays
+      [
+        [boolean | number | string, ...Array<boolean | number | string>],
+        ...Array<
+          [boolean | number | string, ...Array<boolean | number | string>]
+        >,
+      ]
+    >();
+  });
 });
diff --git a/src/chunk.test.ts b/src/chunk.test.ts
index 0b40dfb..726a21b 100644
--- a/src/chunk.test.ts
+++ b/src/chunk.test.ts
@@ -2,33 +2,58 @@ import { chunk } from "./chunk";
 
 describe("data first", () => {
   test("equal size", () => {
-    expect(chunk(["a", "b", "c", "d"] as const, 2)).toEqual([
+    expect(chunk(["a", "b", "c", "d"], 2)).toEqual([
       ["a", "b"],
       ["c", "d"],
     ]);
   });
 
   test("not equal size", () => {
-    expect(chunk(["a", "b", "c", "d"] as const, 3)).toEqual([
-      ["a", "b", "c"],
-      ["d"],
-    ]);
+    expect(chunk(["a", "b", "c", "d"], 3)).toEqual([["a", "b", "c"], ["d"]]);
   });
 
   test("1 element", () => {
-    expect(chunk(["x"] as const, 3)).toEqual([["x"]]);
+    expect(chunk(["x"], 3)).toEqual([["x"]]);
   });
 
   test("empty array", () => {
-    expect(chunk([] as const, 3)).toEqual([]);
+    expect(chunk([], 3)).toEqual([]);
   });
 });
 
 describe("data last", () => {
   test("equal size", () => {
-    expect(chunk(2)(["a", "b", "c", "d"] as const)).toEqual([
+    expect(chunk(2)(["a", "b", "c", "d"])).toEqual([
       ["a", "b"],
       ["c", "d"],
     ]);
   });
 });
+
+describe("edge-cases", () => {
+  it("throws on 0 size", () => {
+    expect(() => chunk(["a", "b", "c", "d"], 0)).toThrow(
+      "chunk: A chunk size of '0' would result in an infinite array",
+    );
+  });
+
+  it("throws on negative size", () => {
+    expect(() => chunk(["a", "b", "c", "d"], -10)).toThrow(
+      "chunk: A chunk size of '-10' would result in an infinite array",
+    );
+  });
+
+  test("size > data.length", () => {
+    const data = [1, 2, 3] as const;
+    const result = chunk(data, 10);
+    expect(result).toStrictEqual([data]);
+    // We expect the result to be a shallow clone
+    expect(result[0]).not.toBe(data);
+  });
+
+  test("chunk of size 1", () => {
+    const data = [1, 2, 3, 4, 5] as const;
+    const result = chunk(data, 1);
+    expect(result).toStrictEqual([[1], [2], [3], [4], [5]]);
+  });
+});
diff --git a/src/internal/types.test-d.ts b/src/internal/types.test-d.ts
index 3f4cf77..dbedd6d 100644
--- a/src/internal/types.test-d.ts
+++ b/src/internal/types.test-d.ts
@@ -4,6 +4,8 @@ import {
   type IfBoundedRecord,
   type EnumerableStringKeyedValueOf,
   type EnumerableStringKeyOf,
+  type NTuple,
+  type TupleParts,
 } from "./types";
 
 declare const SymbolFoo: unique symbol;
@@ -306,3 +308,165 @@ describe("EnumerableStringKeyedValueOf", () => {
     >().toEqualTypeOf<never>();
   });
 });
+
+describe("NTuple", () => {
+  test("size 0", () => {
+    const result = nTuple("foo", 0);
+    expectTypeOf(result).toEqualTypeOf<[]>();
+  });
+
+  test("non-trivial size", () => {
+    const result = nTuple("foo", 3);
+    expectTypeOf(result).toEqualTypeOf<[string, string, string]>();
+  });
+});
+
+describe("TupleParts", () => {
+  describe("mutable", () => {
+    test("empty array", () => {
+      const data = [] as [];
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [];
+        item: never;
+        suffix: [];
+      }>();
+    });
+
+    test("regular array", () => {
+      const data = [] as Array<number>;
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [];
+        item: number;
+        suffix: [];
+      }>();
+    });
+
+    test("fixed tuple", () => {
+      const data = [1, 2, 3] as [1, 2, 3];
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [1, 2, 3];
+        item: never;
+        suffix: [];
+      }>();
+    });
+
+    test("array with prefix", () => {
+      const data = ["a"] as [string, ...Array<boolean>];
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [string];
+        item: boolean;
+        suffix: [];
+      }>();
+    });
+
+    test("array with suffix", () => {
+      const data = ["a"] as [...Array<boolean>, string];
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [];
+        item: boolean;
+        suffix: [string];
+      }>();
+    });
+
+    test("array with prefix and suffix", () => {
+      const data = [1, "a"] as [number, ...Array<boolean>, string];
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [number];
+        item: boolean;
+        suffix: [string];
+      }>();
+    });
+  });
+
+  describe("readonly", () => {
+    test("empty array", () => {
+      const data = [] as const;
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [];
+        item: never;
+        suffix: [];
+      }>();
+    });
+
+    test("regular array", () => {
+      const data = [] as ReadonlyArray<number>;
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [];
+        item: number;
+        suffix: [];
+      }>();
+    });
+
+    test("fixed tuple", () => {
+      const data = [1, 2, 3] as const;
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [1, 2, 3];
+        item: never;
+        suffix: [];
+      }>();
+    });
+
+    test("array with prefix", () => {
+      const data = ["a"] as readonly [string, ...Array<boolean>];
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [string];
+        item: boolean;
+        suffix: [];
+      }>();
+    });
+
+    test("array with suffix", () => {
+      const data = ["a"] as readonly [...Array<boolean>, string];
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [];
+        item: boolean;
+        suffix: [string];
+      }>();
+    });
+
+    test("array with prefix and suffix", () => {
+      const data = [1, "a"] as readonly [number, ...Array<boolean>, string];
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<{
+        prefix: [number];
+        item: boolean;
+        suffix: [string];
+      }>();
+    });
+  });
+
+  describe("unions", () => {
+    test("union of arrays", () => {
+      const data = [] as Array<boolean> | Array<number>;
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<
+        | { prefix: []; item: boolean; suffix: [] }
+        | { prefix: []; item: number; suffix: [] }
+      >();
+    });
+
+    test("mixed unions", () => {
+      const data = [] as Array<boolean> | [number, string];
+      const result = tupleParts(data);
+      expectTypeOf(result).toEqualTypeOf<
+        | { prefix: []; item: boolean; suffix: [] }
+        | { prefix: [number, string]; item: never; suffix: [] }
+      >();
+    });
+  });
+});
+
+declare function nTuple<T, N extends number>(x: T, n: N): NTuple<T, N>;
+
+declare function tupleParts<T>(x: T): TupleParts<T>;