Repository: remeda/remeda
Pull request #924 (2024-11-15 15:57:38)

fix(toSnakeCase, toKebabCase): literal types for literal strings
Using the now exported `Words` utility from type-fest we can improve the typing for snake-case and kebab-case to take advantage of it.

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/924/builds/560981) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 31bcd9cdaceb50e8f2272b3824e59a02c4fb1891:

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

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/924?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 [(`263ed17`)](https://app.codecov.io/gh/remeda/remeda/commit/263ed17f2ef53abe260028ebb33b63a7f389e015?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) to head [(`31bcd9c`)](https://app.codecov.io/gh/remeda/remeda/commit/31bcd9cdaceb50e8f2272b3824e59a02c4fb1891?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      #924   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          347       347           
  Lines        11068     11069    +1     
  Branches      2814      2814           
=========================================
+ Hits         11068     11069    +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/924?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/package-lock.json b/package-lock.json
index 63b69fa..861dc6d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
       "version": "2.0.0",
       "license": "MIT",
       "dependencies": {
-        "type-fest": "^4.26.1"
+        "type-fest": "^4.27.0"
       },
       "devDependencies": {
         "@arethetypeswrong/cli": "^0.16.4",
@@ -10903,9 +10903,9 @@
       }
     },
     "node_modules/type-fest": {
-      "version": "4.26.1",
-      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.1.tgz",
-      "integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==",
+      "version": "4.27.0",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.27.0.tgz",
+      "integrity": "sha512-3IMSWgP7C5KSQqmo1wjhKrwsvXAtF33jO3QY+Uy++ia7hqvgSK6iXbbg5PbDBc1P2ZbNEDgejOrN4YooXvhwCw==",
       "license": "(MIT OR CC0-1.0)",
       "engines": {
         "node": ">=16"
diff --git a/package.json b/package.json
index fbf3da4..7755ebc 100644
--- a/package.json
+++ b/package.json
@@ -61,7 +61,7 @@
     "test:typing": "vitest --typecheck.only --typecheck.ignoreSourceErrors"
   },
   "dependencies": {
-    "type-fest": "^4.26.1"
+    "type-fest": "^4.27.0"
   },
   "devDependencies": {
     "@arethetypeswrong/cli": "^0.16.4",
diff --git a/src/internal/splitWords.ts b/src/internal/words.ts
similarity index 88%
rename from src/internal/splitWords.ts
rename to src/internal/words.ts
index dcda765..2e9ceb7 100644
--- a/src/internal/splitWords.ts
+++ b/src/internal/words.ts
@@ -1,3 +1,5 @@
+import type { Words } from "type-fest";
+
 // @see https://github.com/sindresorhus/type-fest/blob/main/source/internal/characters.d.ts#L5-L31
 const WHITESPACE = [
   "\u{9}", // '\t'
@@ -51,5 +53,8 @@ const WORD_SPLITTING_RE = new RegExp(
   "u",
 );
 
-export const splitWords = (data: string): Array<string> =>
+export const words = <S extends string>(
+  data: S,
+): string extends S ? Array<string> : Words<S> =>
+  // @ts-expect-error [ts2322] -- TypeScript can't infer this type...
   data.split(WORD_SPLITTING_RE).filter(({ length }) => length > 0);
diff --git a/src/toCamelCase.ts b/src/toCamelCase.ts
index 54b0e8b..a5127fe 100644
--- a/src/toCamelCase.ts
+++ b/src/toCamelCase.ts
@@ -1,5 +1,5 @@
 import type { CamelCase } from "type-fest";
-import { splitWords } from "./internal/splitWords";
+import { words } from "./internal/words";
 
 const LOWER_CASE_CHARACTER_RE = /[a-z]/u;
 
@@ -94,7 +94,7 @@ const toCamelCaseImplementation = (
     preserveConsecutiveUppercase = DEFAULT_OPTIONS.preserveConsecutiveUppercase,
   }: CamelCaseOptions = {},
 ): string =>
-  splitWords(
+  words(
     LOWER_CASE_CHARACTER_RE.test(data)
       ? data
       : // If the text doesn't have **any** lower case characters we also lower
diff --git a/src/toKebabCase.ts b/src/toKebabCase.ts
index 6b59157..144eb1f 100644
--- a/src/toKebabCase.ts
+++ b/src/toKebabCase.ts
@@ -1,6 +1,11 @@
-import { splitWords } from "./internal/splitWords";
+import type { Join, Words } from "type-fest";
+import { words } from "./internal/words";
 import { purry } from "./purry";
 
+type KebabCase<S extends string> = string extends S
+  ? string
+  : Lowercase<Join<Words<S>, "-">>;
+
 /**
  * Convert a text to kebab-Case by splitting it into words and joining them back
  * together with "-", and then lowering the case of the result.
@@ -19,7 +24,7 @@ import { purry } from "./purry";
  * @dataFirst
  * @category String
  */
-export function toKebabCase(data: string): string;
+export function toKebabCase<S extends string>(data: S): KebabCase<S>;
 
 /**
  * Convert a text to kebabCase by splitting it into words and joining them back
@@ -38,11 +43,15 @@ export function toKebabCase(data: string): string;
  * @dataLast
  * @category String
  */
-export function toKebabCase(): (data: string) => string;
+export function toKebabCase(): <S extends string>(data: S) => KebabCase<S>;
 
 export function toKebabCase(...args: ReadonlyArray<unknown>): unknown {
   return purry(toKebabCaseImplementation, args);
 }
 
-const toKebabCaseImplementation = (data: string): string =>
-  splitWords(data).join("-").toLowerCase();
+const toKebabCaseImplementation = <S extends string>(data: S): KebabCase<S> =>
+  // @ts-expect-error [ts2322] -- To avoid importing our own utilities for this
+  // we are using the built-in `join` and `toLowerCase` functions which aren't
+  // typed as well. This is equivalent to `toLowerCase(join(words(data), "-"))`
+  // which TypeScript infers correctly as KebabCase.
+  words(data).join("-").toLowerCase();
diff --git a/src/toSnakeCase.ts b/src/toSnakeCase.ts
index bc90855..f7c30b3 100644
--- a/src/toSnakeCase.ts
+++ b/src/toSnakeCase.ts
@@ -1,6 +1,11 @@
-import { splitWords } from "./internal/splitWords";
+import type { Join, Words } from "type-fest";
+import { words } from "./internal/words";
 import { purry } from "./purry";
 
+type SnakeCase<S extends string> = string extends S
+  ? string
+  : Lowercase<Join<Words<S>, "_">>;
+
 /**
  * Convert a text to snake-case by splitting it into words and joining them back
  * together with "_", and then lowering the case of the result.
@@ -19,7 +24,7 @@ import { purry } from "./purry";
  * @dataFirst
  * @category String
  */
-export function toSnakeCase(data: string): string;
+export function toSnakeCase<S extends string>(data: S): SnakeCase<S>;
 
 /**
  * Convert a text to snake-case by splitting it into words and joining them back
@@ -38,11 +43,15 @@ export function toSnakeCase(data: string): string;
  * @dataLast
  * @category String
  */
-export function toSnakeCase(): (data: string) => string;
+export function toSnakeCase(): <S extends string>(data: S) => SnakeCase<S>;
 
 export function toSnakeCase(...args: ReadonlyArray<unknown>): unknown {
   return purry(toSnakeCaseImplementation, args);
 }
 
-const toSnakeCaseImplementation = (data: string): string =>
-  splitWords(data).join("_").toLowerCase();
+const toSnakeCaseImplementation = <S extends string>(data: S): SnakeCase<S> =>
+  // @ts-expect-error [ts2322] -- To avoid importing our own utilities for this
+  // we are using the built-in `join` and `toLowerCase` functions which aren't
+  // typed as well. This is equivalent to `toLowerCase(join(words(data), "_"))`
+  // which TypeScript infers correctly as SnakeCase.
+  words(data).join("_").toLowerCase();


Test patch:
diff --git a/src/internal/splitWords.test.ts b/src/internal/words.test.ts
similarity index 96%
rename from src/internal/splitWords.test.ts
rename to src/internal/words.test.ts
index 5869063..867763d 100644
--- a/src/internal/splitWords.test.ts
+++ b/src/internal/words.test.ts
@@ -1,4 +1,4 @@
-import { splitWords } from "./splitWords";
+import { words } from "./words";
 
 describe("copied from the type-fest tests", () => {
   test.each([
@@ -66,7 +66,7 @@ describe("copied from the type-fest tests", () => {
     { input: "BBBBa", output: ["BBB", "Ba"] },
     { input: "BBBBB", output: ["BBBBB"] },
   ])("case changes: $input", ({ input, output }) => {
-    expect(splitWords(input)).toStrictEqual(output);
+    expect(words(input)).toStrictEqual(output);
   });
 
   test.each([
@@ -92,7 +92,7 @@ describe("copied from the type-fest tests", () => {
     { input: "hello WORLD-lowercase", output: ["hello", "WORLD", "lowercase"] },
     { input: "hello WORLD Uppercase", output: ["hello", "WORLD", "Uppercase"] },
   ])("white spaces: $input", ({ input, output }) => {
-    expect(splitWords(input)).toStrictEqual(output);
+    expect(words(input)).toStrictEqual(output);
   });
 
   test.each([
@@ -109,6 +109,6 @@ describe("copied from the type-fest tests", () => {
       output: ["item", "0", "item", "1", "item", "2"],
     },
   ])("digits: $input", ({ input, output }) => {
-    expect(splitWords(input)).toStrictEqual(output);
+    expect(words(input)).toStrictEqual(output);
   });
 });
diff --git a/src/toKebabCase.test-d.ts b/src/toKebabCase.test-d.ts
new file mode 100644
index 0000000..b30e08f
--- /dev/null
+++ b/src/toKebabCase.test-d.ts
@@ -0,0 +1,57 @@
+import { expectTypeOf } from "expect-type";
+import { toKebabCase } from "./toKebabCase";
+
+test("primitive string", () => {
+  const result = toKebabCase("hello world" as string);
+  expectTypeOf(result).toEqualTypeOf<string>();
+});
+
+test("empty string", () => {
+  const result = toKebabCase("" as const);
+  expectTypeOf(result).toEqualTypeOf<"">();
+});
+
+test("camelCase", () => {
+  const result = toKebabCase("helloWorld" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello-world">();
+});
+
+test("spaces and mixed cases", () => {
+  const result = toKebabCase("Hello World" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello-world">();
+});
+
+test("spaces and lower case", () => {
+  const result = toKebabCase("hello world" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello-world">();
+});
+
+test("spaces and UPPERCASE", () => {
+  const result = toKebabCase("HELLO WORLD" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello-world">();
+});
+
+test("snake_case", () => {
+  const result = toKebabCase("hello_world" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello-world">();
+});
+
+test("kebab-case", () => {
+  const result = toKebabCase("hello-world" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello-world">();
+});
+
+test("string with multiple delimiters", () => {
+  const result = toKebabCase("foo---bar" as const);
+  expectTypeOf(result).toEqualTypeOf<"foo-bar">();
+});
+
+test("numbers", () => {
+  const result = toKebabCase("helloWorld123" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello-world-123">();
+});
+
+test("string with special characters", () => {
+  const result = toKebabCase("hello@world!" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello-@world-!">();
+});
diff --git a/src/toSnakeCase.test-d.ts b/src/toSnakeCase.test-d.ts
new file mode 100644
index 0000000..1e94894
--- /dev/null
+++ b/src/toSnakeCase.test-d.ts
@@ -0,0 +1,57 @@
+import { expectTypeOf } from "expect-type";
+import { toSnakeCase } from "./toSnakeCase";
+
+test("primitive string", () => {
+  const result = toSnakeCase("hello world" as string);
+  expectTypeOf(result).toEqualTypeOf<string>();
+});
+
+test("empty string", () => {
+  const result = toSnakeCase("" as const);
+  expectTypeOf(result).toEqualTypeOf<"">();
+});
+
+test("camelCase", () => {
+  const result = toSnakeCase("helloWorld" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello_world">();
+});
+
+test("spaces and mixed cases", () => {
+  const result = toSnakeCase("Hello World" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello_world">();
+});
+
+test("spaces and lower case", () => {
+  const result = toSnakeCase("hello world" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello_world">();
+});
+
+test("spaces and UPPERCASE", () => {
+  const result = toSnakeCase("HELLO WORLD" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello_world">();
+});
+
+test("snake_case", () => {
+  const result = toSnakeCase("hello_world" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello_world">();
+});
+
+test("kebab-case", () => {
+  const result = toSnakeCase("hello-world" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello_world">();
+});
+
+test("string with multiple delimiters", () => {
+  const result = toSnakeCase("foo___bar" as const);
+  expectTypeOf(result).toEqualTypeOf<"foo_bar">();
+});
+
+test("numbers", () => {
+  const result = toSnakeCase("helloWorld123" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello_world_123">();
+});
+
+test("string with special characters", () => {
+  const result = toSnakeCase("hello@world!" as const);
+  expectTypeOf(result).toEqualTypeOf<"hello_@world_!">();
+});