Repository: remeda/remeda
Pull request #790 (2024-07-24 15:36:07)

feat: Add toUpperCase, toLowerCase, capitalize, uncapitalize, toCamelCase, startsWith, endsWith
Closes: #509

Added the following ECMAScript built-ins:
* `toUpperCase`
* `toLowerCase`
* `startsWith`
* `endsWith`

The TypeScript utilities:
* `capitalize`
* `uncapitalize`

And the type-fest-based:
* `toCamelCase`

---

Make sure that you:

- [x] Typedoc added for new methods and updated for changed
- [x] Tests added for new methods and updated for changed
- [x] New methods added to `src/index.ts`
- [x] New methods added to `docs/src/content/mapping` for both Lodash and Ramda.



codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/remeda/remeda/pr/790/builds/525819) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit cbe0d3cfa0d4638694004366115cd8b93fc7d8a6:

|Sandbox| Source |
|--|--|
|[remeda-example-vanilla](https://codesandbox.io/s/remeda-example-vanilla-r4whtz)| Configuration |

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/790?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`fde46e0`)](https://app.codecov.io/gh/remeda/remeda/commit/fde46e0a755cc2e0c3b6c0c95c437295ec172501?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) to head [(`cbe0d3c`)](https://app.codecov.io/gh/remeda/remeda/commit/cbe0d3cfa0d4638694004366115cd8b93fc7d8a6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #790   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          292       296    +4     
  Lines         9941     10001   +60     
  Branches      2424      2444   +20     
=========================================
+ Hits          9941     10001   +60     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/790?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).


Patch:
diff --git a/docs/src/content/mapping/lodash/__MISSING.md b/docs/src/content/mapping/lodash/__MISSING.md
index 352c4b5..4f0a738 100644
--- a/docs/src/content/mapping/lodash/__MISSING.md
+++ b/docs/src/content/mapping/lodash/__MISSING.md
@@ -130,15 +130,12 @@ TODO: Go over: https://you-dont-need.github.io/You-Dont-Need-Lodash-Underscore/#
 
 # String
 
-- camelCase
 - capitalize
 - deburr
-- endsWith
 - escape
 - escapeRegExp
 - kebabCase
 - lowerCase
-- lowerFirst
 - pad
 - padEnd
 - padStart
@@ -147,17 +144,13 @@ TODO: Go over: https://you-dont-need.github.io/You-Dont-Need-Lodash-Underscore/#
 - replace
 - snakeCase
 - startCase
-- startsWith
 - template
-- toLower
-- toUpper
 - trim
 - trimEnd
 - trimStart
 - truncate
 - unescape
 - upperCase
-- upperFirst
 
 # Util
 
diff --git a/docs/src/content/mapping/lodash/camelCase.md b/docs/src/content/mapping/lodash/camelCase.md
new file mode 100644
index 0000000..aae741f
--- /dev/null
+++ b/docs/src/content/mapping/lodash/camelCase.md
@@ -0,0 +1,4 @@
+---
+category: String
+remeda: toCamelCase
+---
diff --git a/docs/src/content/mapping/lodash/endsWith.md b/docs/src/content/mapping/lodash/endsWith.md
new file mode 100644
index 0000000..8854314
--- /dev/null
+++ b/docs/src/content/mapping/lodash/endsWith.md
@@ -0,0 +1,4 @@
+---
+category: String
+remeda: endsWith
+---
diff --git a/docs/src/content/mapping/lodash/lowerFirst.md b/docs/src/content/mapping/lodash/lowerFirst.md
new file mode 100644
index 0000000..0c4f4a8
--- /dev/null
+++ b/docs/src/content/mapping/lodash/lowerFirst.md
@@ -0,0 +1,4 @@
+---
+category: String
+remeda: uncapitalize
+---
diff --git a/docs/src/content/mapping/lodash/startsWith.md b/docs/src/content/mapping/lodash/startsWith.md
new file mode 100644
index 0000000..52f874a
--- /dev/null
+++ b/docs/src/content/mapping/lodash/startsWith.md
@@ -0,0 +1,4 @@
+---
+category: String
+remeda: startsWith
+---
diff --git a/docs/src/content/mapping/lodash/toLower.md b/docs/src/content/mapping/lodash/toLower.md
new file mode 100644
index 0000000..a12c7c4
--- /dev/null
+++ b/docs/src/content/mapping/lodash/toLower.md
@@ -0,0 +1,4 @@
+---
+category: String
+remeda: toLowerCase
+---
diff --git a/docs/src/content/mapping/lodash/toUpper.md b/docs/src/content/mapping/lodash/toUpper.md
new file mode 100644
index 0000000..2c5f655
--- /dev/null
+++ b/docs/src/content/mapping/lodash/toUpper.md
@@ -0,0 +1,4 @@
+---
+category: String
+remeda: toUpperCase
+---
diff --git a/docs/src/content/mapping/lodash/upperFirst.md b/docs/src/content/mapping/lodash/upperFirst.md
new file mode 100644
index 0000000..c526b05
--- /dev/null
+++ b/docs/src/content/mapping/lodash/upperFirst.md
@@ -0,0 +1,4 @@
+---
+category: String
+remeda: capitalize
+---
diff --git a/docs/src/content/mapping/ramda/__MISSING.md b/docs/src/content/mapping/ramda/__MISSING.md
index 1d3a898..c1c1ed6 100644
--- a/docs/src/content/mapping/ramda/__MISSING.md
+++ b/docs/src/content/mapping/ramda/__MISSING.md
@@ -171,9 +171,7 @@
 - match
 - replace
 - test
-- toLower
 - toString
-- toUpper
 - trim
 
 # Type
diff --git a/docs/src/content/mapping/ramda/toLower.md b/docs/src/content/mapping/ramda/toLower.md
new file mode 100644
index 0000000..a12c7c4
--- /dev/null
+++ b/docs/src/content/mapping/ramda/toLower.md
@@ -0,0 +1,4 @@
+---
+category: String
+remeda: toLowerCase
+---
diff --git a/docs/src/content/mapping/ramda/toUpper.md b/docs/src/content/mapping/ramda/toUpper.md
new file mode 100644
index 0000000..2c5f655
--- /dev/null
+++ b/docs/src/content/mapping/ramda/toUpper.md
@@ -0,0 +1,4 @@
+---
+category: String
+remeda: toUpperCase
+---
diff --git a/src/capitalize.ts b/src/capitalize.ts
new file mode 100644
index 0000000..4ce9b04
--- /dev/null
+++ b/src/capitalize.ts
@@ -0,0 +1,55 @@
+import { purry } from "./purry";
+
+/**
+ * Makes first character of a string upper-case. Uses the built-in
+ * [`String.prototype.toUpperCase`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase)
+ * for the runtime, and the built-in [`Capitalize`](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html#capitalizestringtype)
+ * utility type for typing.
+ *
+ * For other case manipulations see: `toUpperCase`, `toLowerCase`,
+ * `uncapitalize`, and `toCamelCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ * If the output is intended for display (on a browser) consider using
+ * [the `text-transform: capitalize;` CSS property](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform)
+ * instead!
+ *
+ * @param data - A string.
+ * @signature
+ *   R.capitalize(data);
+ * @example
+ *   R.capitalize("hello world"); // "Hello world"
+ * @dataFirst
+ * @category String
+ */
+export function capitalize<T extends string>(data: T): Capitalize<T>;
+
+/**
+ * Makes first character of a string upper-case. Uses the built-in
+ * [`String.prototype.toUpperCase`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase)
+ * for the runtime, and the built-in [`Capitalize`](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html#capitalizestringtype)
+ * utility type for typing.
+ *
+ * For other case manipulations see: `toUpperCase`, `toLowerCase`,
+ * `uncapitalize`, and `toCamelCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ * If the output is intended for display (on a browser) consider using
+ * [the `text-transform: capitalize;` CSS property](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform)
+ * instead!
+ *
+ * @signature
+ *   R.capitalize()(data);
+ * @example
+ *   R.pipe("hello world", R.capitalize()); // "Hello world"
+ * @dataLast
+ * @category String
+ */
+export function capitalize(): <T extends string>(data: T) => Capitalize<T>;
+
+export function capitalize(...args: ReadonlyArray<unknown>): unknown {
+  return purry(capitalizeImplementation, args);
+}
+
+const capitalizeImplementation = <T extends string>(data: T): Capitalize<T> =>
+  `${data[0]?.toUpperCase() ?? ""}${data.slice(1)}` as Capitalize<T>;
diff --git a/src/endsWith.ts b/src/endsWith.ts
new file mode 100644
index 0000000..b7916ba
--- /dev/null
+++ b/src/endsWith.ts
@@ -0,0 +1,46 @@
+import { purry } from "./purry";
+
+/**
+ * Determines whether the string ends with the provided suffix, and refines
+ * the output if possible. Uses the built-in [`String.prototype.endsWith`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith).
+ *
+ * @param data - The input string.
+ * @param suffix - The prefix to check for.
+ * @signature
+ *   R.endsWith(data, suffix);
+ * @example
+ *   R.endsWith("hello world", "hello"); // false
+ *   R.endsWith("hello world", "world"); // true
+ * @dataFirst
+ * @category String
+ */
+export function endsWith<T extends string, Suffix extends string>(
+  data: T,
+  suffix: string extends Suffix ? never : Suffix,
+): data is T & `${string}${Suffix}`;
+export function endsWith(data: string, suffix: string): boolean;
+
+/**
+ * Determines whether the string ends with the provided suffix, and refines
+ * the output if possible. Uses the built-in [`String.prototype.endsWith`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith).
+ *
+ * @param suffix - The prefix to check for.
+ * @signature
+ *   R.endsWith(data, suffix);
+ * @example
+ *   R.endsWith("hello world", "hello"); // false
+ *   R.endsWith("hello world", "world"); // true
+ * @dataLast
+ * @category String
+ */
+export function endsWith<Suffix extends string>(
+  suffix: string extends Suffix ? never : Suffix,
+): <T extends string>(data: T) => data is T & `${string}${Suffix}`;
+export function endsWith(suffix: string): (data: string) => boolean;
+
+export function endsWith(...args: ReadonlyArray<unknown>): unknown {
+  return purry(endsWithImplementation, args);
+}
+
+const endsWithImplementation = (data: string, suffix: string): boolean =>
+  data.endsWith(suffix);
diff --git a/src/index.ts b/src/index.ts
index 2bb39ee..06f9a20 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +2,7 @@ export * from "./add";
 export * from "./addProp";
 export * from "./allPass";
 export * from "./anyPass";
+export * from "./capitalize";
 export * from "./ceil";
 export * from "./chunk";
 export * from "./clamp";
@@ -19,6 +20,7 @@ export * from "./dropFirstBy";
 export * from "./dropLast";
 export * from "./dropLastWhile";
 export * from "./dropWhile";
+export * from "./endsWith";
 export * from "./entries";
 export * from "./evolve";
 export * from "./filter";
@@ -118,6 +120,7 @@ export * from "./splice";
 export * from "./split";
 export * from "./splitAt";
 export * from "./splitWhen";
+export * from "./startsWith";
 export * from "./stringToPath";
 export * from "./subtract";
 export * from "./sum";
@@ -131,6 +134,10 @@ export * from "./takeLastWhile";
 export * from "./takeWhile";
 export * from "./tap";
 export * from "./times";
+export * from "./toCamelCase";
+export * from "./toLowerCase";
+export * from "./toUpperCase";
+export * from "./uncapitalize";
 export * from "./unique";
 export * from "./uniqueBy";
 export * from "./uniqueWith";
diff --git a/src/internal/splitWords.ts b/src/internal/splitWords.ts
new file mode 100644
index 0000000..dcda765
--- /dev/null
+++ b/src/internal/splitWords.ts
@@ -0,0 +1,55 @@
+// @see https://github.com/sindresorhus/type-fest/blob/main/source/internal/characters.d.ts#L5-L31
+const WHITESPACE = [
+  "\u{9}", // '\t'
+  "\u{A}", // '\n'
+  "\u{B}", // '\v'
+  "\u{C}", // '\f'
+  "\u{D}", // '\r'
+  "\u{20}", // ' '
+  "\u{85}",
+  "\u{A0}",
+  "\u{1680}",
+  "\u{2000}",
+  "\u{2001}",
+  "\u{2002}",
+  "\u{2003}",
+  "\u{2004}",
+  "\u{2005}",
+  "\u{2006}",
+  "\u{2007}",
+  "\u{2008}",
+  "\u{2009}",
+  "\u{200A}",
+  "\u{2028}",
+  "\u{2029}",
+  "\u{202F}",
+  "\u{205F}",
+  "\u{3000}",
+  "\u{FEFF}",
+] as const;
+
+// @see https://github.com/sindresorhus/type-fest/blob/main/source/internal/characters.d.ts#L33
+const WORD_SEPARATORS = ["-", "_", ...WHITESPACE] as const;
+
+// We are basing our logic on the type definition of SplitWords from type-fest.
+// @see https://github.com/sindresorhus/type-fest/blob/main/source/split-words.d.ts
+const WORD_SPLITTING_RE = new RegExp(
+  [
+    // **on** any word separator, these would be removed from the output.
+    `[${WORD_SEPARATORS.join("")}]+`,
+    // When the text transitions from a non-digit to a digit.
+    String.raw`(?<=\D)(?=\d)`,
+    // When the text transitions from a digit to a non-digit.
+    String.raw`(?<=\d)(?=\D)`,
+    // When the text transitions from a lower case to upper case.
+    String.raw`(?<=[a-z])(?=[A-Z])`,
+    // When the text transitions from 2 upper case letters to a lower case
+    // letter. (one upper case letter is considered part of the word, e.g.
+    // "Dog").
+    String.raw`(?<=[A-Z])(?=[A-Z][a-z])`,
+  ].join("|"),
+  "u",
+);
+
+export const splitWords = (data: string): Array<string> =>
+  data.split(WORD_SPLITTING_RE).filter(({ length }) => length > 0);
diff --git a/src/startsWith.ts b/src/startsWith.ts
new file mode 100644
index 0000000..66a4fa4
--- /dev/null
+++ b/src/startsWith.ts
@@ -0,0 +1,46 @@
+import { purry } from "./purry";
+
+/**
+ * Determines whether the string begins with the provided prefix, and refines
+ * the output if possible. Uses the built-in [`String.prototype.startsWith`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith).
+ *
+ * @param data - The input string.
+ * @param prefix - The prefix to check for.
+ * @signature
+ *   R.startsWith(data, prefix);
+ * @example
+ *   R.startsWith("hello world", "hello"); // true
+ *   R.startsWith("hello world", "world"); // false
+ * @dataFirst
+ * @category String
+ */
+export function startsWith<T extends string, Prefix extends string>(
+  data: T,
+  prefix: string extends Prefix ? never : Prefix,
+): data is T & `${Prefix}${string}`;
+export function startsWith(data: string, prefix: string): boolean;
+
+/**
+ * Determines whether the string begins with the provided prefix, and refines
+ * the output if possible. Uses the built-in [`String.prototype.startsWith`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith).
+ *
+ * @param prefix - The prefix to check for.
+ * @signature
+ *   R.startsWith(data, prefix);
+ * @example
+ *   R.startsWith("hello world", "hello"); // true
+ *   R.startsWith("hello world", "world"); // false
+ * @dataLast
+ * @category String
+ */
+export function startsWith<Prefix extends string>(
+  prefix: string extends Prefix ? never : Prefix,
+): <T extends string>(data: T) => data is T & `${Prefix}${string}`;
+export function startsWith(prefix: string): (data: string) => boolean;
+
+export function startsWith(...args: ReadonlyArray<unknown>): unknown {
+  return purry(startsWithImplementation, args);
+}
+
+const startsWithImplementation = (data: string, prefix: string): boolean =>
+  data.startsWith(prefix);
diff --git a/src/toCamelCase.ts b/src/toCamelCase.ts
new file mode 100644
index 0000000..4ecd0e8
--- /dev/null
+++ b/src/toCamelCase.ts
@@ -0,0 +1,101 @@
+import { type CamelCase } from "type-fest";
+import { splitWords } from "./internal/splitWords";
+
+const LOWER_CASE_CHARACTER_RE = /[a-z]/u;
+
+type CamelCaseOptions = {
+  readonly preserveConsecutiveUppercase?: boolean;
+};
+
+const DEFAULT_OPTIONS = {
+  // Same as type-fest's default.
+  preserveConsecutiveUppercase: true,
+} as const satisfies CamelCaseOptions;
+
+/**
+ * Convert a text to camelCase by splitting it into words, un-capitalizing the
+ * first word, capitalizing the rest, then joining them back together. This is
+ * the runtime implementation of type-fest's [`CamelCase` type](https://github.com/sindresorhus/type-fest/blob/main/source/camel-case.d.ts).
+ *
+ * For other case manipulations see: `toLowerCase`, `toUpperCase`, `capitalize`,
+ * and `uncapitalize`.
+ *
+ * For *PascalCase* use `capitalize(toCamelCase(data))`.
+ *
+ * @param data - A string.
+ * @param options - Used to disable the default behavior of preserving
+ * consecutive uppercase characters. This is optional.
+ * @signature
+ *   R.toCamelCase(data);
+ * @example
+ *   R.toCamelCase("hello world"); // "helloWorld"
+ *   R.toCamelCase("__HELLO_WORLD__"); // "helloWorld"
+ * @dataFirst
+ * @category String
+ */
+export function toCamelCase<
+  T extends string,
+  Options extends CamelCaseOptions = typeof DEFAULT_OPTIONS,
+>(data: T, options?: Options): CamelCase<T, Options>;
+
+/**
+ * Convert a text to camelCase by splitting it into words, un-capitalizing the
+ * first word, capitalizing the rest, then joining them back together. This is
+ * the runtime implementation of type-fest's [`CamelCase` type](https://github.com/sindresorhus/type-fest/blob/main/source/camel-case.d.ts).
+ *
+ * For other case manipulations see: `toLowerCase`, `toUpperCase`, `capitalize`,
+ * and `uncapitalize`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ *
+ * For *PascalCase* use `capitalize(toCamelCase(data))`.
+ *
+ * @param options - Used to disable the default behavior of preserving
+ * consecutive uppercase characters. This is optional.
+ * @signature
+ *   R.toCamelCase()(data);
+ * @example
+ *   R.pipe("hello world", R.toCamelCase()); // "helloWorld"
+ *   R.pipe("__HELLO_WORLD__", toCamelCase()); // "helloWorld"
+ * @dataLast
+ * @category String
+ */
+export function toCamelCase<
+  Options extends CamelCaseOptions = typeof DEFAULT_OPTIONS,
+>(options?: Options): <T extends string>(data: T) => CamelCase<T, Options>;
+
+export function toCamelCase(
+  dataOrOptions: CamelCaseOptions | string,
+  options?: CamelCaseOptions,
+): unknown {
+  return typeof dataOrOptions === "string"
+    ? toCamelCaseImplementation(dataOrOptions, options)
+    : (data: string) => toCamelCaseImplementation(data, dataOrOptions);
+}
+
+// Based on the type definition from type-fest.
+// @see https://github.com/sindresorhus/type-fest/blob/main/source/camel-case.d.ts#L76-L80
+const toCamelCaseImplementation = (
+  data: string,
+  {
+    preserveConsecutiveUppercase = DEFAULT_OPTIONS.preserveConsecutiveUppercase,
+  }: CamelCaseOptions = {},
+): string =>
+  splitWords(
+    LOWER_CASE_CHARACTER_RE.test(data)
+      ? data
+      : // If the text doesn't have **any** lower case characters we also lower
+        // case everything, but if it does we need to maintain them as it
+        // affects the word boundaries.
+        data.toLowerCase(),
+  )
+    .map(
+      (word, index) =>
+        `${
+          (index === 0
+            ? // The first word is uncapitalized, the rest are capitalized
+              word[0]?.toLowerCase()
+            : word[0]?.toUpperCase()) ?? ""
+        }${preserveConsecutiveUppercase ? word.slice(1) : word.slice(1).toLowerCase()}`,
+    )
+    .join("");
diff --git a/src/toLowerCase.ts b/src/toLowerCase.ts
new file mode 100644
index 0000000..bf09cc1
--- /dev/null
+++ b/src/toLowerCase.ts
@@ -0,0 +1,53 @@
+import { purry } from "./purry";
+
+/**
+ * Lowers the case of all characters in the input. Uses the built-in [`String.prototype.toLowerCase`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase)
+ * for the runtime, and the built-in [`Lowercase`](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html#lowercasestringtype)
+ * utility type for typing.
+ *
+ * For other case manipulations see: `toUpperCase`, `capitalize`,
+ * `uncapitalize`, and `toCamelCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ * If the output is intended for display (on a browser) consider using
+ * [the `text-transform: lowercase;` CSS property](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform)
+ * instead!
+ *
+ * @param data - A string.
+ * @signature
+ *   R.toLowerCase(data);
+ * @example
+ *   R.toLowerCase("Hello World"); // "hello world"
+ * @dataFirst
+ * @category String
+ */
+export function toLowerCase<T extends string>(data: T): Lowercase<T>;
+
+/**
+ * Lowers the case of all characters in the input. Uses the built-in [`String.prototype.toLowerCase`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase)
+ * for the runtime, and the built-in [`Lowercase`](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html#lowercasestringtype)
+ * utility type for typing.
+ *
+ * For other case manipulations see: `toUpperCase`, `capitalize`,
+ * `uncapitalize`, and `toCamelCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ * If the output is intended for display (on a browser) consider using
+ * [the `text-transform" lowercase;` CSS property](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform)
+ * instead!
+ *
+ * @signature
+ *   R.toLowerCase()(data);
+ * @example
+ *   R.pipe("Hello World", R.toLowerCase()); // "hello world"
+ * @dataLast
+ * @category String
+ */
+export function toLowerCase(): <T extends string>(data: T) => Lowercase<T>;
+
+export function toLowerCase(...args: ReadonlyArray<unknown>): unknown {
+  return purry(toLowerCaseImplementation, args);
+}
+
+const toLowerCaseImplementation = <T extends string>(data: T): Lowercase<T> =>
+  data.toLowerCase() as Lowercase<T>;
diff --git a/src/toUpperCase.ts b/src/toUpperCase.ts
new file mode 100644
index 0000000..6435f19
--- /dev/null
+++ b/src/toUpperCase.ts
@@ -0,0 +1,55 @@
+import { purry } from "./purry";
+
+/**
+ * Replaces all lower-case characters to their upper-case equivalent in the
+ * input. Uses the built-in [`String.prototype.toUpperCase`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase)
+ * for the runtime, and the built-in [`Uppercase`](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html#uppercasestringtype)
+ * utility type for typing.
+ *
+ * For other case manipulations see: `toLowerCase`, `capitalize`,
+ * `uncapitalize`, and `toCamelCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ * If the output is intended for display (on a browser) consider using
+ * [the `text-transform: uppercase;` CSS property](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform)
+ * instead!
+ *
+ * @param data - A string.
+ * @signature
+ *   R.toUpperCase(data);
+ * @example
+ *   R.toUpperCase("Hello World"); // "HELLO WORLD"
+ * @dataFirst
+ * @category String
+ */
+export function toUpperCase<T extends string>(data: T): Uppercase<T>;
+
+/**
+ * Replaces all lower-case characters to their upper-case equivalent in the
+ * input. Uses the built-in [`String.prototype.toUpperCase`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase)
+ * for the runtime, and the built-in [`Uppercase`](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html#uppercasestringtype)
+ * utility type for typing.
+ *
+ * For other case manipulations see: `toLowerCase`, `capitalize`,
+ * `uncapitalize`, and `toCamelCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ * If the output is intended for display (on a browser) consider using
+ * [the `text-transform: uppercase;` CSS property](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform)
+ * instead!
+ *
+ * @signature
+ *   R.toUpperCase()(data);
+ * @example
+ *   R.pipe("Hello World", R.toUpperCase()); // "HELLO WORLD"
+ * @dataLast
+ * @category String
+ */
+export function toUpperCase(): <T extends string>(data: T) => Uppercase<T>;
+
+export function toUpperCase(...args: ReadonlyArray<unknown>): unknown {
+  return purry(toUpperCaseImplementation, args);
+}
+
+const toUpperCaseImplementation = <T extends string>(data: T): Uppercase<T> =>
+  data.toUpperCase() as Uppercase<T>;
diff --git a/src/uncapitalize.ts b/src/uncapitalize.ts
new file mode 100644
index 0000000..83f2f26
--- /dev/null
+++ b/src/uncapitalize.ts
@@ -0,0 +1,49 @@
+import { purry } from "./purry";
+
+/**
+ * Makes first character of a string lower-case. Uses the built-in
+ * [`String.prototype.toLowerCase`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase)
+ * for the runtime, and the built-in [`Uncapitalize`](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html#Uncapitalizestringtype)
+ * utility type for typing.
+ *
+ * For other case manipulations see: `toUpperCase`, `toLowerCase`, `capitalize`,
+ * and `toCamelCase`.
+ *
+ * @param data - A string.
+ * @signature
+ *   R.uncapitalize(data);
+ * @example
+ *   R.uncapitalize("HELLO WORLD"); // "hELLO WORLD"
+ * @dataFirst
+ * @category String
+ */
+export function uncapitalize<T extends string>(data: T): Uncapitalize<T>;
+
+/**
+ * Makes first character of a string upper-case. Uses the built-in
+ * [`String.prototype.toLowerCase`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase)
+ * for the runtime, and the built-in [`Uncapitalize`](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html#Uncapitalizestringtype)
+ * utility type for typing.
+ *
+ * For other case manipulations see: `toUpperCase`, `toLowerCase`, `capitalize`,
+ * and `toCamelCase`.
+ *
+ * !IMPORTANT: This function might work _incorrectly_ for **non-ascii** inputs.
+ *
+ * @signature
+ *   R.uncapitalize()(data);
+ * @example
+ *   R.pipe("HELLO WORLD", R.uncapitalize()); // "hELLO WORLD"
+ * @dataLast
+ * @category String
+ */
+export function uncapitalize(): <T extends string>(data: T) => Uncapitalize<T>;
+
+export function uncapitalize(...args: ReadonlyArray<unknown>): unknown {
+  return purry(uncapitalizeImplementation, args);
+}
+
+const uncapitalizeImplementation = <T extends string>(
+  data: T,
+): Uncapitalize<T> =>
+  `${data[0]?.toLowerCase() ?? ""}${data.slice(1)}` as Uncapitalize<T>;


Test patch:
diff --git a/src/capitalize.test-d.ts b/src/capitalize.test-d.ts
new file mode 100644
index 0000000..a98b07f
--- /dev/null
+++ b/src/capitalize.test-d.ts
@@ -0,0 +1,66 @@
+import { pipe } from "./pipe";
+import { capitalize } from "./capitalize";
+
+describe("data-first", () => {
+  test("on lower case", () => {
+    const result = capitalize("hello world");
+    expectTypeOf(result).toEqualTypeOf<"Hello world">();
+  });
+
+  test("on upper case", () => {
+    const result = capitalize("HELLO WORLD");
+    expectTypeOf(result).toEqualTypeOf<"HELLO WORLD">();
+  });
+
+  test("on mixed case", () => {
+    const result = capitalize("heLlO WoRlD");
+    expectTypeOf(result).toEqualTypeOf<"HeLlO WoRlD">();
+  });
+
+  test("on empty string", () => {
+    const result = capitalize("");
+    expectTypeOf(result).toEqualTypeOf<"">();
+  });
+
+  test("on non-literal string", () => {
+    const result = capitalize("hello world" as string);
+    expectTypeOf(result).toEqualTypeOf<Capitalize<string>>();
+  });
+
+  test("on template literal type", () => {
+    const result = capitalize("prefix_123" as `prefix_${number}`);
+    expectTypeOf(result).toEqualTypeOf<`Prefix_${number}`>();
+  });
+});
+
+describe("data-last", () => {
+  test("on lower case", () => {
+    const result = pipe("hello world" as const, capitalize());
+    expectTypeOf(result).toEqualTypeOf<"Hello world">();
+  });
+
+  test("on upper case", () => {
+    const result = pipe("HELLO WORLD" as const, capitalize());
+    expectTypeOf(result).toEqualTypeOf<"HELLO WORLD">();
+  });
+
+  test("on mixed case", () => {
+    const result = pipe("heLlO WoRlD" as const, capitalize());
+    expectTypeOf(result).toEqualTypeOf<"HeLlO WoRlD">();
+  });
+
+  test("on empty string", () => {
+    const result = pipe("" as const, capitalize());
+    expectTypeOf(result).toEqualTypeOf<"">();
+  });
+
+  test("on non-literal string", () => {
+    const result = pipe("hello world" as string, capitalize());
+    expectTypeOf(result).toEqualTypeOf<Capitalize<string>>();
+  });
+
+  test("on template literal type", () => {
+    const result = pipe("prefix_123" as `prefix_${number}`, capitalize());
+    expectTypeOf(result).toEqualTypeOf<`Prefix_${number}`>();
+  });
+});
diff --git a/src/capitalize.test.ts b/src/capitalize.test.ts
new file mode 100644
index 0000000..3bb7e92
--- /dev/null
+++ b/src/capitalize.test.ts
@@ -0,0 +1,38 @@
+import { pipe } from "./pipe";
+import { capitalize } from "./capitalize";
+
+describe("data-first", () => {
+  test("empty string", () => {
+    expect(capitalize("")).toBe("");
+  });
+
+  test("on lower case", () => {
+    expect(capitalize("hello world")).toBe("Hello world");
+  });
+
+  test("on upper case", () => {
+    expect(capitalize("HELLO WORLD")).toBe("HELLO WORLD");
+  });
+
+  test("on mixed case", () => {
+    expect(capitalize("heLlO WoRlD")).toBe("HeLlO WoRlD");
+  });
+});
+
+describe("data-last", () => {
+  test("empty string", () => {
+    expect(pipe("", capitalize())).toBe("");
+  });
+
+  test("on lower case", () => {
+    expect(pipe("hello world", capitalize())).toBe("Hello world");
+  });
+
+  test("on upper case", () => {
+    expect(pipe("HELLO WORLD", capitalize())).toBe("HELLO WORLD");
+  });
+
+  test("on mixed case", () => {
+    expect(pipe("heLlO WoRlD", capitalize())).toBe("HeLlO WoRlD");
+  });
+});
diff --git a/src/endsWith.test-d.ts b/src/endsWith.test-d.ts
new file mode 100644
index 0000000..2f1f206
--- /dev/null
+++ b/src/endsWith.test-d.ts
@@ -0,0 +1,143 @@
+/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion --
+ * This is required to write the tests properly, otherwise vitest fails to
+ * assert that our input data is a `const`.
+ */
+
+import { partition } from "./partition";
+import { endsWith } from "./endsWith";
+
+describe("data-first", () => {
+  test("doesn't narrow on 'string' prefix", () => {
+    const data = "foobar" as string;
+    if (endsWith(data, "bar" as string)) {
+      expectTypeOf(data).toEqualTypeOf<string>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<string>();
+    }
+  });
+
+  test("const data that matches", () => {
+    const data = "foobar" as const;
+    if (endsWith(data, "bar")) {
+      expectTypeOf(data).toEqualTypeOf<"foobar">();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<never>();
+    }
+  });
+
+  test("const data that doesn't match", () => {
+    const data = "helloworld" as const;
+    if (endsWith(data, "bar")) {
+      expectTypeOf(data).toEqualTypeOf<never>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<"helloworld">();
+    }
+  });
+
+  test("primitive string data", () => {
+    const data = "foobar" as string;
+    if (endsWith(data, "bar")) {
+      expectTypeOf(data).toEqualTypeOf<`${string}bar`>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<string>();
+    }
+  });
+
+  test("template literal data that matches", () => {
+    const data = "1_bar" as `${number}_bar`;
+    if (endsWith(data, "bar")) {
+      expectTypeOf(data).toEqualTypeOf<`${number}_bar`>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<never>();
+    }
+  });
+
+  test("template literal data that doesn't match", () => {
+    const data = "1_bar" as `${number}_bar`;
+    if (endsWith(data, "world")) {
+      // These should be equivalent to `never` but TypeScript doesn't infer
+      // that...
+      expectTypeOf(data).toEqualTypeOf<`${number}_bar` & `${string}world`>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<`${number}_bar`>();
+    }
+  });
+
+  test("literal union", () => {
+    const data = "cat" as "cat" | "dog";
+    if (endsWith(data, "t")) {
+      expectTypeOf(data).toEqualTypeOf<"cat">();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<"dog">();
+    }
+  });
+
+  test("template union", () => {
+    const data = "cat" as `${boolean}_dog` | `${number}_cat`;
+    if (endsWith(data, "t")) {
+      expectTypeOf(data).toEqualTypeOf<`${number}_cat`>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<`${boolean}_dog`>();
+    }
+  });
+});
+
+describe("data-last", () => {
+  test("doesn't narrow on 'string' prefix", () => {
+    const [yes, no] = partition([] as Array<string>, endsWith("bar" as string));
+    expectTypeOf(yes).toEqualTypeOf<Array<string>>();
+    expectTypeOf(no).toEqualTypeOf<Array<string>>();
+  });
+
+  test("const data that matches", () => {
+    const [yes, no] = partition([] as Array<"foobar">, endsWith("bar"));
+    expectTypeOf(yes).toEqualTypeOf<Array<"foobar">>();
+    expectTypeOf(no).toEqualTypeOf<Array<never>>();
+  });
+
+  test("const data that doesn't match", () => {
+    const [yes, no] = partition([] as Array<"helloworld">, endsWith("bar"));
+    expectTypeOf(yes).toEqualTypeOf<Array<never>>();
+    expectTypeOf(no).toEqualTypeOf<Array<"helloworld">>();
+  });
+
+  test("primitive string data", () => {
+    const [yes, no] = partition([] as Array<string>, endsWith("bar"));
+    expectTypeOf(yes).toEqualTypeOf<Array<`${string}bar`>>();
+    expectTypeOf(no).toEqualTypeOf<Array<string>>();
+  });
+
+  test("template literal data that matches", () => {
+    const [yes, no] = partition([] as Array<`${number}_bar`>, endsWith("bar"));
+    expectTypeOf(yes).branded.toEqualTypeOf<Array<`${number}_bar`>>();
+    expectTypeOf(no).toEqualTypeOf<Array<never>>();
+  });
+
+  test("template literal data that doesn't match", () => {
+    const [yes, no] = partition(
+      [] as Array<`${number}_bar`>,
+      endsWith("world"),
+    );
+    expectTypeOf(yes).toEqualTypeOf<
+      // These should be equivalent to `never` but TypeScript doesn't infer
+      // that...
+      Array<`${number}_bar` & `${string}world`>
+    >();
+    expectTypeOf(no).toEqualTypeOf<Array<`${number}_bar`>>();
+  });
+
+  test("literal union", () => {
+    const [yes, no] = partition([] as Array<"cat" | "dog">, endsWith("t"));
+    expectTypeOf(yes).toEqualTypeOf<Array<"cat">>();
+    expectTypeOf(no).toEqualTypeOf<Array<"dog">>();
+  });
+
+  test("template union", () => {
+    const [yes, no] = partition(
+      [] as Array<`${boolean}_dog` | `${number}_cat`>,
+      endsWith("t"),
+    );
+    expectTypeOf(yes).branded.toEqualTypeOf<Array<`${number}_cat`>>();
+    expectTypeOf(no).toEqualTypeOf<Array<`${boolean}_dog`>>();
+  });
+});
diff --git a/src/endsWith.test.ts b/src/endsWith.test.ts
new file mode 100644
index 0000000..790e6cb
--- /dev/null
+++ b/src/endsWith.test.ts
@@ -0,0 +1,28 @@
+import { pipe } from "./pipe";
+import { endsWith } from "./endsWith";
+
+test("empty data", () => {
+  expect(endsWith("", "")).toBe(true);
+  expect(endsWith("", "hellO")).toBe(false);
+});
+
+test("ends with", () => {
+  expect(endsWith("hello world", "world")).toBe(true);
+  expect(endsWith("hello world", " world")).toBe(true);
+  expect(endsWith("hello world", "hello world")).toBe(true);
+});
+
+test("doesn't ends with", () => {
+  expect(endsWith("hello world", "hello")).toBe(false);
+  expect(endsWith("hello world", "hello ")).toBe(false);
+  expect(endsWith("hello world", "hello world ")).toBe(false);
+});
+
+test("matches case", () => {
+  expect(endsWith("hello world", "world")).toBe(true);
+  expect(endsWith("hello world", "World")).toBe(false);
+});
+
+test("data-last", () => {
+  expect(pipe("hello world", endsWith("world"))).toBe(true);
+});
diff --git a/src/internal/splitWords.test.ts b/src/internal/splitWords.test.ts
new file mode 100644
index 0000000..e146dce
--- /dev/null
+++ b/src/internal/splitWords.test.ts
@@ -0,0 +1,114 @@
+import { splitWords } from "./splitWords";
+
+describe("copied from the type-fest tests", () => {
+  test.each([
+    { input: "", output: [] },
+    { input: "a", output: ["a"] },
+    { input: "B", output: ["B"] },
+    { input: "aa", output: ["aa"] },
+    { input: "aB", output: ["a", "B"] },
+    { input: "Ba", output: ["Ba"] },
+    { input: "BB", output: ["BB"] },
+    { input: "aaa", output: ["aaa"] },
+    { input: "aaB", output: ["aa", "B"] },
+    { input: "aBa", output: ["a", "Ba"] },
+    { input: "aBB", output: ["a", "BB"] },
+    { input: "Baa", output: ["Baa"] },
+    { input: "BaB", output: ["Ba", "B"] },
+    { input: "BBa", output: ["B", "Ba"] },
+    { input: "BBB", output: ["BBB"] },
+    { input: "aaaa", output: ["aaaa"] },
+    { input: "aaaB", output: ["aaa", "B"] },
+    { input: "aaBa", output: ["aa", "Ba"] },
+    { input: "aaBB", output: ["aa", "BB"] },
+    { input: "aBaa", output: ["a", "Baa"] },
+    { input: "aBaB", output: ["a", "Ba", "B"] },
+    { input: "aBBa", output: ["a", "B", "Ba"] },
+    { input: "aBBB", output: ["a", "BBB"] },
+    { input: "Baaa", output: ["Baaa"] },
+    { input: "BaaB", output: ["Baa", "B"] },
+    { input: "BaBa", output: ["Ba", "Ba"] },
+    { input: "BaBB", output: ["Ba", "BB"] },
+    { input: "BBaa", output: ["B", "Baa"] },
+    { input: "BBaB", output: ["B", "Ba", "B"] },
+    { input: "BBBa", output: ["BB", "Ba"] },
+    { input: "BBBB", output: ["BBBB"] },
+    { input: "aaaaa", output: ["aaaaa"] },
+    { input: "aaaaB", output: ["aaaa", "B"] },
+    { input: "aaaBa", output: ["aaa", "Ba"] },
+    { input: "aaaBB", output: ["aaa", "BB"] },
+    { input: "aaBaa", output: ["aa", "Baa"] },
+    { input: "aaBaB", output: ["aa", "Ba", "B"] },
+    { input: "aaBBa", output: ["aa", "B", "Ba"] },
+    { input: "aaBBB", output: ["aa", "BBB"] },
+    { input: "aBaaa", output: ["a", "Baaa"] },
+    { input: "aBaaB", output: ["a", "Baa", "B"] },
+    { input: "aBaBa", output: ["a", "Ba", "Ba"] },
+    { input: "aBaBB", output: ["a", "Ba", "BB"] },
+    { input: "aBBaa", output: ["a", "B", "Baa"] },
+    { input: "aBBaB", output: ["a", "B", "Ba", "B"] },
+    { input: "aBBBa", output: ["a", "BB", "Ba"] },
+    { input: "aBBBB", output: ["a", "BBBB"] },
+    { input: "Baaaa", output: ["Baaaa"] },
+    { input: "BaaaB", output: ["Baaa", "B"] },
+    { input: "BaaBa", output: ["Baa", "Ba"] },
+    { input: "BaaBB", output: ["Baa", "BB"] },
+    { input: "BaBaa", output: ["Ba", "Baa"] },
+    { input: "BaBaB", output: ["Ba", "Ba", "B"] },
+    { input: "BaBBa", output: ["Ba", "B", "Ba"] },
+    { input: "BaBBB", output: ["Ba", "BBB"] },
+    { input: "BBaaa", output: ["B", "Baaa"] },
+    { input: "BBaaB", output: ["B", "Baa", "B"] },
+    { input: "BBaBa", output: ["B", "Ba", "Ba"] },
+    { input: "BBaBB", output: ["B", "Ba", "BB"] },
+    { input: "BBBaa", output: ["BB", "Baa"] },
+    { input: "BBBaB", output: ["BB", "Ba", "B"] },
+    { input: "BBBBa", output: ["BBB", "Ba"] },
+    { input: "BBBBB", output: ["BBBBB"] },
+  ])("case changes: $input", ({ input, output }) => {
+    expect(splitWords(input)).toEqual(output);
+  });
+
+  test.each([
+    { input: "hello world", output: ["hello", "world"] },
+    { input: "Hello-world", output: ["Hello", "world"] },
+    { input: "hello_world", output: ["hello", "world"] },
+    { input: "hello  world", output: ["hello", "world"] },
+    { input: "Hello--world", output: ["Hello", "world"] },
+    { input: "hello__world", output: ["hello", "world"] },
+    { input: "   hello  world", output: ["hello", "world"] },
+    { input: "---Hello--world", output: ["Hello", "world"] },
+    { input: "___hello__world", output: ["hello", "world"] },
+    { input: "hello  world   ", output: ["hello", "world"] },
+    { input: "hello\tworld", output: ["hello", "world"] },
+    { input: "Hello--world--", output: ["Hello", "world"] },
+    { input: "hello__world___", output: ["hello", "world"] },
+    { input: "___ hello -__  _world", output: ["hello", "world"] },
+    {
+      input: "__HelloWorld-HELLOWorld helloWORLD",
+      output: ["Hello", "World", "HELLO", "World", "hello", "WORLD"],
+    },
+    { input: "hello WORLD lowercase", output: ["hello", "WORLD", "lowercase"] },
+    { input: "hello WORLD-lowercase", output: ["hello", "WORLD", "lowercase"] },
+    { input: "hello WORLD Uppercase", output: ["hello", "WORLD", "Uppercase"] },
+  ])("white spaces: $input", ({ input, output }) => {
+    expect(splitWords(input)).toEqual(output);
+  });
+
+  test.each([
+    { input: "item0", output: ["item", "0"] },
+    { input: "item01", output: ["item", "01"] },
+    { input: "item10", output: ["item", "10"] },
+    { input: "item010", output: ["item", "010"] },
+    { input: "0item0", output: ["0", "item", "0"] },
+    { input: "01item01", output: ["01", "item", "01"] },
+    { input: "10item10", output: ["10", "item", "10"] },
+    { input: "010item010", output: ["010", "item", "010"] },
+    {
+      input: "item0_item_1 item -2",
+      output: ["item", "0", "item", "1", "item", "2"],
+    },
+  ])("digits: $input", ({ input, output }) => {
+    expect(splitWords(input)).toEqual(output);
+  });
+});
diff --git a/src/startsWith.test-d.ts b/src/startsWith.test-d.ts
new file mode 100644
index 0000000..0f6e9c6
--- /dev/null
+++ b/src/startsWith.test-d.ts
@@ -0,0 +1,149 @@
+/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion --
+ * This is required to write the tests properly, otherwise vitest fails to
+ * assert that our input data is a `const`.
+ */
+
+import { partition } from "./partition";
+import { startsWith } from "./startsWith";
+
+describe("data-first", () => {
+  test("doesn't narrow on 'string' prefix", () => {
+    const data = "foobar" as string;
+    if (startsWith(data, "foo" as string)) {
+      expectTypeOf(data).toEqualTypeOf<string>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<string>();
+    }
+  });
+
+  test("const data that matches", () => {
+    const data = "foobar" as const;
+    if (startsWith(data, "foo")) {
+      expectTypeOf(data).toEqualTypeOf<"foobar">();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<never>();
+    }
+  });
+
+  test("const data that doesn't match", () => {
+    const data = "helloworld" as const;
+    if (startsWith(data, "foo")) {
+      expectTypeOf(data).toEqualTypeOf<never>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<"helloworld">();
+    }
+  });
+
+  test("primitive string data", () => {
+    const data = "foobar" as string;
+    if (startsWith(data, "foo")) {
+      expectTypeOf(data).toEqualTypeOf<`foo${string}`>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<string>();
+    }
+  });
+
+  test("template literal data that matches", () => {
+    const data = "foo_1" as `foo_${number}`;
+    if (startsWith(data, "foo")) {
+      expectTypeOf(data).toEqualTypeOf<`foo_${number}`>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<never>();
+    }
+  });
+
+  test("template literal data that doesn't match", () => {
+    const data = "foo_1" as `foo_${number}`;
+    if (startsWith(data, "hello")) {
+      // These should be equivalent to `never` but TypeScript doesn't infer
+      // that...
+      expectTypeOf(data).toEqualTypeOf<`foo_${number}` & `hello${string}`>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<`foo_${number}`>();
+    }
+  });
+
+  test("literal union", () => {
+    const data = "cat" as "cat" | "dog";
+    if (startsWith(data, "c")) {
+      expectTypeOf(data).toEqualTypeOf<"cat">();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<"dog">();
+    }
+  });
+
+  test("template union", () => {
+    const data = "cat" as `cat_${number}` | `dog_${boolean}`;
+    if (startsWith(data, "c")) {
+      expectTypeOf(data).toEqualTypeOf<`cat_${number}`>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<`dog_${boolean}`>();
+    }
+  });
+});
+
+describe("data-last", () => {
+  test("doesn't narrow on 'string' prefix", () => {
+    const [yes, no] = partition(
+      [] as Array<string>,
+      startsWith("foo" as string),
+    );
+    expectTypeOf(yes).toEqualTypeOf<Array<string>>();
+    expectTypeOf(no).toEqualTypeOf<Array<string>>();
+  });
+
+  test("const data that matches", () => {
+    const [yes, no] = partition([] as Array<"foobar">, startsWith("foo"));
+    expectTypeOf(yes).toEqualTypeOf<Array<"foobar">>();
+    expectTypeOf(no).toEqualTypeOf<Array<never>>();
+  });
+
+  test("const data that doesn't match", () => {
+    const [yes, no] = partition([] as Array<"helloworld">, startsWith("foo"));
+    expectTypeOf(yes).toEqualTypeOf<Array<never>>();
+    expectTypeOf(no).toEqualTypeOf<Array<"helloworld">>();
+  });
+
+  test("primitive string data", () => {
+    const [yes, no] = partition([] as Array<string>, startsWith("foo"));
+    expectTypeOf(yes).toEqualTypeOf<Array<`foo${string}`>>();
+    expectTypeOf(no).toEqualTypeOf<Array<string>>();
+  });
+
+  test("template literal data that matches", () => {
+    const [yes, no] = partition(
+      [] as Array<`foo_${number}`>,
+      startsWith("foo"),
+    );
+    expectTypeOf(yes).branded.toEqualTypeOf<Array<`foo_${number}`>>();
+    expectTypeOf(no).toEqualTypeOf<Array<never>>();
+  });
+
+  test("template literal data that doesn't match", () => {
+    const [yes, no] = partition(
+      [] as Array<`foo_${number}`>,
+      startsWith("hello"),
+    );
+    expectTypeOf(yes).toEqualTypeOf<
+      // These should be equivalent to `never` but TypeScript doesn't infer
+      // that...
+      Array<`foo_${number}` & `hello${string}`>
+    >();
+    expectTypeOf(no).toEqualTypeOf<Array<`foo_${number}`>>();
+  });
+
+  test("literal union", () => {
+    const [yes, no] = partition([] as Array<"cat" | "dog">, startsWith("c"));
+    expectTypeOf(yes).toEqualTypeOf<Array<"cat">>();
+    expectTypeOf(no).toEqualTypeOf<Array<"dog">>();
+  });
+
+  test("template union", () => {
+    const [yes, no] = partition(
+      [] as Array<`cat_${number}` | `dog_${boolean}`>,
+      startsWith("c"),
+    );
+    expectTypeOf(yes).branded.toEqualTypeOf<Array<`cat_${number}`>>();
+    expectTypeOf(no).toEqualTypeOf<Array<`dog_${boolean}`>>();
+  });
+});
diff --git a/src/startsWith.test.ts b/src/startsWith.test.ts
new file mode 100644
index 0000000..5afdea7
--- /dev/null
+++ b/src/startsWith.test.ts
@@ -0,0 +1,28 @@
+import { pipe } from "./pipe";
+import { startsWith } from "./startsWith";
+
+test("empty data", () => {
+  expect(startsWith("", "")).toBe(true);
+  expect(startsWith("", "hellO")).toBe(false);
+});
+
+test("starts with", () => {
+  expect(startsWith("hello world", "hello")).toBe(true);
+  expect(startsWith("hello world", "hello ")).toBe(true);
+  expect(startsWith("hello world", "hello world")).toBe(true);
+});
+
+test("doesn't start with", () => {
+  expect(startsWith("hello world", "hello world ")).toBe(false);
+  expect(startsWith("hello world", "world")).toBe(false);
+  expect(startsWith("hello world", "world ")).toBe(false);
+});
+
+test("matches case", () => {
+  expect(startsWith("hello world", "hello")).toBe(true);
+  expect(startsWith("hello world", "Hello")).toBe(false);
+});
+
+test("data-last", () => {
+  expect(pipe("hello world", startsWith("hello"))).toBe(true);
+});
diff --git a/src/toCamelCase.test-d.ts b/src/toCamelCase.test-d.ts
new file mode 100644
index 0000000..3cd00d8
--- /dev/null
+++ b/src/toCamelCase.test-d.ts
@@ -0,0 +1,156 @@
+import { pipe } from "./pipe";
+import { toCamelCase } from "./toCamelCase";
+
+describe("data-last", () => {
+  test("without options", () => {
+    const result = pipe("hello world" as const, toCamelCase());
+    expectTypeOf(result).toEqualTypeOf<"helloWorld">();
+  });
+
+  test("with options (preserveConsecutiveUppercase: true)", () => {
+    const result = pipe(
+      "fooBAR" as const,
+      toCamelCase({ preserveConsecutiveUppercase: true }),
+    );
+    expectTypeOf(result).toEqualTypeOf<"fooBAR">();
+  });
+
+  test("with options (preserveConsecutiveUppercase: false)", () => {
+    const result = pipe(
+      "fooBAR" as const,
+      toCamelCase({ preserveConsecutiveUppercase: false }),
+    );
+    expectTypeOf(result).toEqualTypeOf<"fooBar">();
+  });
+});
+
+describe("tests copied from type-fest's tests", () => {
+  test("pascal", () => {
+    const result = toCamelCase("FooBar");
+    expectTypeOf(result).toEqualTypeOf<"fooBar">();
+  });
+
+  test("kebab", () => {
+    const result = toCamelCase("foo-bar");
+    expectTypeOf(result).toEqualTypeOf<"fooBar">();
+  });
+
+  test("complex kebab", () => {
+    const result = toCamelCase("foo-bar-abc-123");
+    expectTypeOf(result).toEqualTypeOf<"fooBarAbc123">();
+  });
+
+  test("space", () => {
+    const result = toCamelCase("foo bar");
+    expectTypeOf(result).toEqualTypeOf<"fooBar">();
+  });
+
+  test("snake", () => {
+    const result = toCamelCase("foo_bar");
+    expectTypeOf(result).toEqualTypeOf<"fooBar">();
+  });
+
+  test("no delimiter from mono", () => {
+    const result = toCamelCase("foobar");
+    expectTypeOf(result).toEqualTypeOf<"foobar">();
+  });
+
+  test("mixed", () => {
+    const result = toCamelCase("foo-bar_abc xyzBarFoo");
+    expectTypeOf(result).toEqualTypeOf<"fooBarAbcXyzBarFoo">();
+  });
+
+  test("vendor prefixed css property", () => {
+    const result = toCamelCase("-webkit-animation");
+    expectTypeOf(result).toEqualTypeOf<"webkitAnimation">();
+  });
+
+  test("double prefixed kebab", () => {
+    const result = toCamelCase("--very-prefixed");
+    expectTypeOf(result).toEqualTypeOf<"veryPrefixed">();
+  });
+
+  test("repeated separators", () => {
+    const result = toCamelCase("foo____bar");
+    expectTypeOf(result).toEqualTypeOf<"fooBar">();
+  });
+
+  test("uppercase", () => {
+    const result = toCamelCase("FOO");
+    expectTypeOf(result).toEqualTypeOf<"foo">();
+  });
+
+  test("lowercase", () => {
+    const result = toCamelCase("foo");
+    expectTypeOf(result).toEqualTypeOf<"foo">();
+  });
+
+  test("screaming snake case", () => {
+    const result = toCamelCase("FOO_BAR");
+    expectTypeOf(result).toEqualTypeOf<"fooBar">();
+  });
+
+  test("screaming kebab case", () => {
+    const result = toCamelCase("FOO-BAR");
+    expectTypeOf(result).toEqualTypeOf<"fooBar">();
+  });
+
+  test("preserveConsecutiveUppercase: fooBAR", () => {
+    const data = "fooBAR";
+    const whenTrue = toCamelCase(data, { preserveConsecutiveUppercase: true });
+    expectTypeOf(whenTrue).toEqualTypeOf<"fooBAR">();
+
+    const whenFalse = toCamelCase(data, {
+      preserveConsecutiveUppercase: false,
+    });
+    expectTypeOf(whenFalse).toEqualTypeOf<"fooBar">();
+  });
+
+  test("preserveConsecutiveUppercase: fooBAR", () => {
+    const data = "fooBARBiz";
+    const whenTrue = toCamelCase(data, { preserveConsecutiveUppercase: true });
+    expectTypeOf(whenTrue).toEqualTypeOf<"fooBARBiz">();
+
+    const whenFalse = toCamelCase(data, {
+      preserveConsecutiveUppercase: false,
+    });
+    expectTypeOf(whenFalse).toEqualTypeOf<"fooBarBiz">();
+  });
+
+  test("preserveConsecutiveUppercase: fooBAR", () => {
+    const data = "foo BAR-Biz_BUZZ";
+    const whenTrue = toCamelCase(data, { preserveConsecutiveUppercase: true });
+    expectTypeOf(whenTrue).toEqualTypeOf<"fooBARBizBUZZ">();
+
+    const whenFalse = toCamelCase(data, {
+      preserveConsecutiveUppercase: false,
+    });
+    expectTypeOf(whenFalse).toEqualTypeOf<"fooBarBizBuzz">();
+  });
+
+  test("preserveConsecutiveUppercase: fooBAR", () => {
+    const data = "foo\tBAR-Biz_BUZZ";
+    const whenTrue = toCamelCase(data, { preserveConsecutiveUppercase: true });
+    expectTypeOf(whenTrue).toEqualTypeOf<"fooBARBizBUZZ">();
+
+    const whenFalse = toCamelCase(data, {
+      preserveConsecutiveUppercase: false,
+    });
+    expectTypeOf(whenFalse).toEqualTypeOf<"fooBarBizBuzz">();
+  });
+});
+
+test("fallback when regular string", () => {
+  const result = toCamelCase("hello world" as string);
+  expectTypeOf(result).toEqualTypeOf<string>();
+});
+
+test("with template literal type (lowercase)", () => {
+  const result = toCamelCase("this_is_1" as `this_is_${number}`);
+  expectTypeOf(result).toEqualTypeOf<`thisIs${Capitalize<`${number}`>}`>();
+});
+
+test("with template literal type (uppercase)", () => {
+  const result = toCamelCase("THIS_IS_1" as `THIS_IS_${number}`);
+  expectTypeOf(result).toEqualTypeOf<`tHISIS${Capitalize<`${number}`>}`>();
+});
diff --git a/src/toCamelCase.test.ts b/src/toCamelCase.test.ts
new file mode 100644
index 0000000..b98127b
--- /dev/null
+++ b/src/toCamelCase.test.ts
@@ -0,0 +1,103 @@
+import { pipe } from "./pipe";
+import { toCamelCase } from "./toCamelCase";
+
+describe("data-last", () => {
+  test("without options", () => {
+    expect(pipe("hello world", toCamelCase())).toBe("helloWorld");
+  });
+
+  test("with options (preserveConsecutiveUppercase: true)", () => {
+    expect(
+      pipe("fooBAR", toCamelCase({ preserveConsecutiveUppercase: true })),
+    ).toBe("fooBAR");
+  });
+
+  test("with options (preserveConsecutiveUppercase: false)", () => {
+    expect(
+      pipe("fooBAR", toCamelCase({ preserveConsecutiveUppercase: false })),
+    ).toBe("fooBar");
+  });
+});
+
+describe("tests copied from type-fest's tests", () => {
+  test("pascal", () => {
+    expect(toCamelCase("FooBar")).toBe("fooBar");
+  });
+
+  test("kebab", () => {
+    expect(toCamelCase("foo-bar")).toBe("fooBar");
+  });
+
+  test("complex kebab", () => {
+    expect(toCamelCase("foo-bar-abc-123")).toBe("fooBarAbc123");
+  });
+
+  test("space", () => {
+    expect(toCamelCase("foo bar")).toBe("fooBar");
+  });
+
+  test("snake", () => {
+    expect(toCamelCase("foo_bar")).toBe("fooBar");
+  });
+
+  test("no delimiter from mono", () => {
+    expect(toCamelCase("foobar")).toBe("foobar");
+  });
+
+  test("mixed", () => {
+    expect(toCamelCase("foo-bar_abc xyzBarFoo")).toBe("fooBarAbcXyzBarFoo");
+  });
+
+  test("vendor prefixed css property", () => {
+    expect(toCamelCase("-webkit-animation")).toBe("webkitAnimation");
+  });
+
+  test("double prefixed kebab", () => {
+    expect(toCamelCase("--very-prefixed")).toBe("veryPrefixed");
+  });
+
+  test("repeated separators", () => {
+    expect(toCamelCase("foo____bar")).toBe("fooBar");
+  });
+
+  test("uppercase", () => {
+    expect(toCamelCase("FOO")).toBe("foo");
+  });
+
+  test("lowercase", () => {
+    expect(toCamelCase("foo")).toBe("foo");
+  });
+
+  test("screaming snake case", () => {
+    expect(toCamelCase("FOO_BAR")).toBe("fooBar");
+  });
+
+  test("screaming kebab case", () => {
+    expect(toCamelCase("FOO-BAR")).toBe("fooBar");
+  });
+
+  test.each([
+    { input: "fooBAR", whenTrue: "fooBAR", whenFalse: "fooBar" },
+    { input: "fooBARBiz", whenTrue: "fooBARBiz", whenFalse: "fooBarBiz" },
+    {
+      input: "foo BAR-Biz_BUZZ",
+      whenTrue: "fooBARBizBUZZ",
+      whenFalse: "fooBarBizBuzz",
+    },
+    {
+      input: "foo\tBAR-Biz_BUZZ",
+      whenTrue: "fooBARBizBUZZ",
+      whenFalse: "fooBarBizBuzz",
+    },
+  ])(
+    "preserveConsecutiveUppercase: $input",
+    ({ input, whenTrue, whenFalse }) => {
+      expect(toCamelCase(input, { preserveConsecutiveUppercase: true })).toBe(
+        whenTrue,
+      );
+      expect(toCamelCase(input, { preserveConsecutiveUppercase: false })).toBe(
+        whenFalse,
+      );
+    },
+  );
+});
diff --git a/src/toLowerCase.test-d.ts b/src/toLowerCase.test-d.ts
new file mode 100644
index 0000000..ec99739
--- /dev/null
+++ b/src/toLowerCase.test-d.ts
@@ -0,0 +1,66 @@
+import { pipe } from "./pipe";
+import { toLowerCase } from "./toLowerCase";
+
+describe("data-first", () => {
+  test("on lower case", () => {
+    const result = toLowerCase("hello world");
+    expectTypeOf(result).toEqualTypeOf<"hello world">();
+  });
+
+  test("on upper case", () => {
+    const result = toLowerCase("HELLO WORLD");
+    expectTypeOf(result).toEqualTypeOf<"hello world">();
+  });
+
+  test("on mixed case", () => {
+    const result = toLowerCase("HeLlO WoRlD");
+    expectTypeOf(result).toEqualTypeOf<"hello world">();
+  });
+
+  test("on empty string", () => {
+    const result = toLowerCase("");
+    expectTypeOf(result).toEqualTypeOf<"">();
+  });
+
+  test("on non-literal string", () => {
+    const result = toLowerCase("hello world" as string);
+    expectTypeOf(result).toEqualTypeOf<Lowercase<string>>();
+  });
+
+  test("on template literal type", () => {
+    const result = toLowerCase("PREFIX_123" as `PREFIX_${number}`);
+    expectTypeOf(result).toEqualTypeOf<`prefix_${Lowercase<`${number}`>}`>();
+  });
+});
+
+describe("data-last", () => {
+  test("on lower case", () => {
+    const result = pipe("hello world" as const, toLowerCase());
+    expectTypeOf(result).toEqualTypeOf<"hello world">();
+  });
+
+  test("on upper case", () => {
+    const result = pipe("HELLO WORLD" as const, toLowerCase());
+    expectTypeOf(result).toEqualTypeOf<"hello world">();
+  });
+
+  test("on mixed case", () => {
+    const result = pipe("HeLlO WoRlD" as const, toLowerCase());
+    expectTypeOf(result).toEqualTypeOf<"hello world">();
+  });
+
+  test("on empty string", () => {
+    const result = pipe("" as const, toLowerCase());
+    expectTypeOf(result).toEqualTypeOf<"">();
+  });
+
+  test("on non-literal string", () => {
+    const result = pipe("hello world" as string, toLowerCase());
+    expectTypeOf(result).toEqualTypeOf<Lowercase<string>>();
+  });
+
+  test("on template literal type", () => {
+    const result = pipe("prefix_123" as `PREFIX_${number}`, toLowerCase());
+    expectTypeOf(result).toEqualTypeOf<`prefix_${Lowercase<`${number}`>}`>();
+  });
+});
diff --git a/src/toLowerCase.test.ts b/src/toLowerCase.test.ts
new file mode 100644
index 0000000..47d3ca1
--- /dev/null
+++ b/src/toLowerCase.test.ts
@@ -0,0 +1,30 @@
+import { pipe } from "./pipe";
+import { toLowerCase } from "./toLowerCase";
+
+describe("data-first", () => {
+  test("on lower case", () => {
+    expect(toLowerCase("hello world")).toBe("hello world");
+  });
+
+  test("on upper case", () => {
+    expect(toLowerCase("HELLO WORLD")).toBe("hello world");
+  });
+
+  test("on mixed case", () => {
+    expect(toLowerCase("HeLlO WoRlD")).toBe("hello world");
+  });
+});
+
+describe("data-last", () => {
+  test("on lower case", () => {
+    expect(pipe("hello world", toLowerCase())).toBe("hello world");
+  });
+
+  test("on upper case", () => {
+    expect(pipe("HELLO WORLD", toLowerCase())).toBe("hello world");
+  });
+
+  test("on mixed case", () => {
+    expect(pipe("HeLlO WoRlD", toLowerCase())).toBe("hello world");
+  });
+});
diff --git a/src/toUpperCase.test-d.ts b/src/toUpperCase.test-d.ts
new file mode 100644
index 0000000..3e3125c
--- /dev/null
+++ b/src/toUpperCase.test-d.ts
@@ -0,0 +1,66 @@
+import { pipe } from "./pipe";
+import { toUpperCase } from "./toUpperCase";
+
+describe("data-first", () => {
+  test("on lower case", () => {
+    const result = toUpperCase("hello world");
+    expectTypeOf(result).toEqualTypeOf<"HELLO WORLD">();
+  });
+
+  test("on upper case", () => {
+    const result = toUpperCase("HELLO WORLD");
+    expectTypeOf(result).toEqualTypeOf<"HELLO WORLD">();
+  });
+
+  test("on mixed case", () => {
+    const result = toUpperCase("HeLlO WoRlD");
+    expectTypeOf(result).toEqualTypeOf<"HELLO WORLD">();
+  });
+
+  test("on empty string", () => {
+    const result = toUpperCase("");
+    expectTypeOf(result).toEqualTypeOf<"">();
+  });
+
+  test("on non-literal string", () => {
+    const result = toUpperCase("hello world" as string);
+    expectTypeOf(result).toEqualTypeOf<Uppercase<string>>();
+  });
+
+  test("on template literal type", () => {
+    const result = toUpperCase("prefix_123" as `prefix_${number}`);
+    expectTypeOf(result).toEqualTypeOf<`PREFIX_${Uppercase<`${number}`>}`>();
+  });
+});
+
+describe("data-last", () => {
+  test("on lower case", () => {
+    const result = pipe("hello world" as const, toUpperCase());
+    expectTypeOf(result).toEqualTypeOf<"HELLO WORLD">();
+  });
+
+  test("on upper case", () => {
+    const result = pipe("HELLO WORLD" as const, toUpperCase());
+    expectTypeOf(result).toEqualTypeOf<"HELLO WORLD">();
+  });
+
+  test("on mixed case", () => {
+    const result = pipe("HeLlO WoRlD" as const, toUpperCase());
+    expectTypeOf(result).toEqualTypeOf<"HELLO WORLD">();
+  });
+
+  test("on empty string", () => {
+    const result = pipe("" as const, toUpperCase());
+    expectTypeOf(result).toEqualTypeOf<"">();
+  });
+
+  test("on non-literal string", () => {
+    const result = pipe("hello world" as string, toUpperCase());
+    expectTypeOf(result).toEqualTypeOf<Uppercase<string>>();
+  });
+
+  test("on template literal type", () => {
+    const result = pipe("prefix_123" as `prefix_${number}`, toUpperCase());
+    expectTypeOf(result).toEqualTypeOf<`PREFIX_${Uppercase<`${number}`>}`>();
+  });
+});
diff --git a/src/toUpperCase.test.ts b/src/toUpperCase.test.ts
new file mode 100644
index 0000000..c78438a
--- /dev/null
+++ b/src/toUpperCase.test.ts
@@ -0,0 +1,30 @@
+import { pipe } from "./pipe";
+import { toUpperCase } from "./toUpperCase";
+
+describe("data-first", () => {
+  test("on lower case", () => {
+    expect(toUpperCase("hello world")).toBe("HELLO WORLD");
+  });
+
+  test("on upper case", () => {
+    expect(toUpperCase("HELLO WORLD")).toBe("HELLO WORLD");
+  });
+
+  test("on mixed case", () => {
+    expect(toUpperCase("HeLlO WoRlD")).toBe("HELLO WORLD");
+  });
+});
+
+describe("data-last", () => {
+  test("on lower case", () => {
+    expect(pipe("hello world", toUpperCase())).toBe("HELLO WORLD");
+  });
+
+  test("on upper case", () => {
+    expect(pipe("HELLO WORLD", toUpperCase())).toBe("HELLO WORLD");
+  });
+
+  test("on mixed case", () => {
+    expect(pipe("HeLlO WoRlD", toUpperCase())).toBe("HELLO WORLD");
+  });
+});
diff --git a/src/uncapitalize.test-d.ts b/src/uncapitalize.test-d.ts
new file mode 100644
index 0000000..d1a1215
--- /dev/null
+++ b/src/uncapitalize.test-d.ts
@@ -0,0 +1,66 @@
+import { pipe } from "./pipe";
+import { uncapitalize } from "./uncapitalize";
+
+describe("data-first", () => {
+  test("on lower case", () => {
+    const result = uncapitalize("hello world");
+    expectTypeOf(result).toEqualTypeOf<"hello world">();
+  });
+
+  test("on upper case", () => {
+    const result = uncapitalize("HELLO WORLD");
+    expectTypeOf(result).toEqualTypeOf<"hELLO WORLD">();
+  });
+
+  test("on mixed case", () => {
+    const result = uncapitalize("HeLlO WoRlD");
+    expectTypeOf(result).toEqualTypeOf<"heLlO WoRlD">();
+  });
+
+  test("on empty string", () => {
+    const result = uncapitalize("");
+    expectTypeOf(result).toEqualTypeOf<"">();
+  });
+
+  test("on non-literal string", () => {
+    const result = uncapitalize("hello world" as string);
+    expectTypeOf(result).toEqualTypeOf<Uncapitalize<string>>();
+  });
+
+  test("on template literal type", () => {
+    const result = uncapitalize("PREFIX_123" as `PREFIX_${number}`);
+    expectTypeOf(result).toEqualTypeOf<`pREFIX_${number}`>();
+  });
+});
+
+describe("data-last", () => {
+  test("on lower case", () => {
+    const result = pipe("hello world" as const, uncapitalize());
+    expectTypeOf(result).toEqualTypeOf<"hello world">();
+  });
+
+  test("on upper case", () => {
+    const result = pipe("HELLO WORLD" as const, uncapitalize());
+    expectTypeOf(result).toEqualTypeOf<"hELLO WORLD">();
+  });
+
+  test("on mixed case", () => {
+    const result = pipe("HeLlO WoRlD" as const, uncapitalize());
+    expectTypeOf(result).toEqualTypeOf<"heLlO WoRlD">();
+  });
+
+  test("on empty string", () => {
+    const result = pipe("" as const, uncapitalize());
+    expectTypeOf(result).toEqualTypeOf<"">();
+  });
+
+  test("on non-literal string", () => {
+    const result = pipe("hello world" as string, uncapitalize());
+    expectTypeOf(result).toEqualTypeOf<Uncapitalize<string>>();
+  });
+
+  test("on template literal type", () => {
+    const result = pipe("PREFIX_123" as `PREFIX_${number}`, uncapitalize());
+    expectTypeOf(result).toEqualTypeOf<`pREFIX_${number}`>();
+  });
+});
diff --git a/src/uncapitalize.test.ts b/src/uncapitalize.test.ts
new file mode 100644
index 0000000..997cc02
--- /dev/null
+++ b/src/uncapitalize.test.ts
@@ -0,0 +1,38 @@
+import { pipe } from "./pipe";
+import { uncapitalize } from "./uncapitalize";
+
+describe("data-first", () => {
+  test("empty string", () => {
+    expect(uncapitalize("")).toBe("");
+  });
+
+  test("on lower case", () => {
+    expect(uncapitalize("hello world")).toBe("hello world");
+  });
+
+  test("on upper case", () => {
+    expect(uncapitalize("HELLO WORLD")).toBe("hELLO WORLD");
+  });
+
+  test("on mixed case", () => {
+    expect(uncapitalize("HeLlO WoRlD")).toBe("heLlO WoRlD");
+  });
+});
+
+describe("data-last", () => {
+  test("empty string", () => {
+    expect(pipe("", uncapitalize())).toBe("");
+  });
+
+  test("on lower case", () => {
+    expect(pipe("hello world", uncapitalize())).toBe("hello world");
+  });
+
+  test("on upper case", () => {
+    expect(pipe("HELLO WORLD", uncapitalize())).toBe("hELLO WORLD");
+  });
+
+  test("on mixed case", () => {
+    expect(pipe("HeLlO WoRlD", uncapitalize())).toBe("heLlO WoRlD");
+  });
+});